Compare commits

...
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 66498415f1 docs(maestro): delete superpowers specs and plans
ci / rust (push) Successful in 2m55s
They described the registry/peer-to-peer design and were kept only for
their superseded banners. CLAUDE.md is the current reference. Repoints
the doc comments that cited them, and fixes the maestro-web README,
which still named MAESTRO_REGISTRY as the Vite proxy override
(MAESTRO_DAEMON_URL since the refactor).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:49:39 +01:00
Miguel PalhasandClaude Opus 5 69586a4da5 refactor(maestro): rename brokerStreamUrl to attachStreamUrl
There is no broker: the SPA opens the daemon's /api/attach/:id directly
through the maestro-web proxy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:49:39 +01:00
Miguel PalhasandClaude Opus 5 8ff5046ff3 refactor(maestro): one default-on-missing config loader
load() and load_or_default() differed only in whether a missing file was
an error. Nothing wants the error: every field has a default. Keeps
load(), drops the MissingFile variant, whose message pointed at the
`maestro daemon configure` subcommand the single-host refactor deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:49:39 +01:00
Miguel PalhasandClaude Opus 5 bf7def5cf1 chore(maestro): drop dead maestro-names crate
Nothing in the workspace depended on it — session handles are generated
client-side in web/src/names.ts, which carried its own copy of the
wordlists. Also drops two unused deps: rust-embed (no crate used it;
maestro-web embeds via include_dir) and urlencoding in maestro-cli.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:49:30 +01:00
Miguel Palhas c856333e4a docs(maestro): describe the single-host architecture
ci / rust (push) Successful in 2m40s
Rewrites CLAUDE.md as the current-architecture reference, drops the
registry from INSTALL/PRODUCT/DESIGN and the web README, deletes the
registry Dockerfile, and marks the dated design specs superseded where
they describe the registry, JWT auth, or cross-host attach.

The installer loses its configure step — every config field now has a
default, so a daemon starts with no config file at all.
2026-08-22 10:26:49 +01:00
Miguel Palhas de097cf17f test(maestro): rework e2e harness for a single daemon
Drops the registry subprocess and the T2 broker scenarios; the harness
spawns one daemon and the client talks straight to its /api/* surface.
2026-08-22 10:21:27 +01:00
Miguel Palhas 1bb464be4b refactor(maestro): flatten web UI to a single host
Session tree nests by working directory only; the daemon chips in the
header become a session tally, and the spawn form loses its host picker.
API calls move from /api/daemons/:id/* to /api/dirs, /api/recent-dirs,
/api/gitroot, and /api/info.
2026-08-22 10:14:10 +01:00
Miguel Palhas 6790f41652 refactor(maestro): point CLI and TUI at the local daemon only
CLI drops the registry fallback, cookie auth, set-registry, and the
--daemon selector; every command hits [cli].daemon_url. TUI drops the
daemon tree from the sidebar and the daemon field from the spawn modal.
GET /api/web/info becomes GET /api/info.
2026-08-22 10:07:12 +01:00
Miguel Palhas d006f4a7bd refactor(maestro): drop registry, p2p, and auth from daemon core
Maestro is now single-host: one daemon owns the machine's sessions and
clients talk to it directly. Removes the registry crate, the daemon's
registry client/broker/catalog mirror, JWT attach auth, cookie auth, and
the perimeter (T2) transport config.

The daemon API is now unauthenticated on its bind address, same trust
model as tmux. Session and CreateSessionRequest lose daemon_id;
RegistryEvent becomes CatalogEvent with no daemon variants; attach moves
to WS /api/attach/:id with no token.
2026-08-22 10:00:24 +01:00
naps62-yoloandClaude Opus 4.8 d6d433770b feat(maestro): swipe to open/close the sidebar drawer on mobile
ci / rust (push) Failing after 1m50s
Horizontal swipes now drive the off-canvas drawer (right opens, left closes)
instead of triggering browser back/forward. Gated to mobile and to clearly
horizontal gestures so vertical terminal scrolling is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:55:04 +00:00
naps62-yoloandClaude Opus 4.8 7d0dc6597a feat(maestro): move mobile ☰ to the main header
ci / rust (push) Failing after 1m45s
The sidebar-drawer toggle now lives left of the logo in the global header
instead of floating over the content area / tab bar. Removes the in-canvas
fab and tab-bar menu button and their styles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:14:28 +00:00
naps62-yoloandClaude Opus 4.8 58b732d400 refactor(maestro): drop folder session counters in sidebar
Remove the per-folder count badge (and the now-unused countLeaves) from the
sidebar tree — the tree structure already conveys grouping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:14:28 +00:00
naps62-yoloandClaude Opus 4.8 347b6bdc2a feat(maestro): forward touch scroll to mouse-tracking apps
ci / rust (push) Failing after 1m40s
On the alternate screen there's no scrollback, but apps like Claude Code and
tmux enable mouse tracking. When term.modes.mouseTrackingMode is active, turn
finger drags into wheel events so xterm encodes them as mouse-wheel reports the
app reads — scrolling its own content. Normal buffer still walks scrollback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:05:57 +00:00
naps62-yoloandClaude Opus 4.8 a1b6c85f8d fix(maestro): reliable touch scrollback on mobile
ci / rust (push) Failing after 1m49s
xterm's native touch scrolling crawled a line at a time and stalled after a
few. Map finger drags on the viewport to term.scrollLines (skipped on the alt
screen, which the app owns) and claim vertical pans via touch-action: pan-y.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:59:49 +00:00
naps62-yoloandClaude Opus 4.8 3b69c98cca fix(maestro): render ☰ on mobile empty state
ci / rust (push) Failing after 1m48s
The welcome/empty-tab branch returned before renderMobile, so with the tab
bar collapsed there was no ☰ — leaving mobile users stuck with no way to
open the sidebar drawer. Float the same ☰ over the welcome view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:55:25 +00:00
naps62-yoloandClaude Opus 4.8 9286eb09ba feat(maestro): tighten mobile session UX
ci / rust (push) Failing after 1m42s
On phones (single tab, one pane at a time):
- drop the pane border/padding/focus-ring so the session fills the screen
- collapse the redundant tab bar; float a ☰ to keep the sidebar drawer reachable
- disable pull-to-refresh (overscroll-behavior) so terminal scroll wins

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:53:26 +00:00
naps62-yoloandClaude Opus 4.8 57d417f7a0 feat(maestro): abbreviate paths in spawn dialog recent-dir picker
ci / rust (push) Failing after 1m43s
The working-directory picker showed raw absolute paths (`/home/naps62/...`),
unlike the sidebar tree which collapses home to `~` and shrinks leading
segments to initials. Reuse the sidebar's abbreviation so both read the same:
`/home/naps62/git.naps.pt/naps62/maestro` → `~/g/n/maestro`, with the full path
kept in the row tooltip and submitted verbatim.

- types.ts: hoist `abbreviateFolderName` (+ a local `escapeHtml`) here so both
  the sidebar and spawn dialog share one implementation (catalog.ts imports
  spawn.ts, so the helper can't live in catalog without a cycle).
- catalog.ts: import the shared helper instead of its own copy.
- spawn.ts: abbreviate each recent-dir row via `abbreviateFolderName(abbreviateCwd(...))`.
- styles.css: dim the collapsed stem in the picker too (`.recent-dir .folder-stem`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:41:52 +00:00
naps62-yoloandClaude Opus 4.8 70c592e884 fix(maestro): surface cross-host attach failures instead of silent close
ci / rust (push) Failing after 2m11s
Cross-host (T2-brokered) attach fails when no shared perimeter is
configured: the daemon rejects the upgrade with a structured 502
(no_perimeter_candidate / no_local_perimeter / perimeter_mismatch /
peer_unreachable), but maestro-web's WS proxy had already upgraded the
browser socket and merely dropped it on the upstream failure — the
attach pane flashed and vanished with no reason shown.

- maestro-web proxy: on a failed upstream handshake, relay the daemon's
  error message to the browser as an application close frame (4502 +
  reason) instead of a bare drop; parse the structured JSON error body.
- web attach.ts: on a 4000+ close with a reason, show it in the pane
  header and terminal body and keep the pane up, rather than auto-closing.
- daemon: WARN at startup when no perimeter_id/private_addr is configured,
  since cross-host attach is then unavailable.
- docs/INSTALL.md: document the required matching perimeter_id + per-host
  private_addr for cross-host attach.

Closes #39

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:08:03 +00:00
103 changed files with 1327 additions and 17355 deletions
-10
View File
@@ -1,10 +0,0 @@
target/
worktrees/
.git/
.github/
.claude/
docs/
web/node_modules/
web/dist/
*.md
.env*
+36 -21
View File
@@ -1,33 +1,56 @@
# Maestro
A unified terminal-session orchestrator. One place — laptop, desktop, or phone — to see and attach to every interactive shell or agent (Claude Code, opencode, plain bash) running across every machine you care about. tmux for persistence, WebSocket for transport, daemon-per-host federated by a small always-on registry.
A terminal-session orchestrator for one machine. One daemon per host owns every
interactive shell or agent (Claude Code, opencode, plain bash) running on it;
clients — CLI, TUI, browser — attach over a plain HTTP + WebSocket API. tmux for
persistence, WebSocket for transport.
## Where the spec lives
## Architecture
`docs/superpowers/specs/2026-05-24-maestro-design.md` is the source of truth for architecture, wire protocol, session model, auth, repo layout, and scope boundary. **Read it before writing any code in this repo.**
```
maestro daemon tmux wrapper + session API on 127.0.0.1:7070
├── GET/POST /api/sessions list / spawn
├── DELETE /api/sessions/:id kill
├── GET /api/info hostname, version, home dir, spawn presets
├── GET /api/dirs directory listing (spawn autocomplete)
├── GET /api/recent-dirs spawn history
├── GET /api/gitroot enclosing repo of a path
├── WS /api/events Snapshot + session deltas
└── WS /api/attach/:id raw PTY bytes
maestro list/spawn/attach/kill CLI client
maestro tui ratatui client
maestro-web serves the SPA, reverse-proxies /api/* to the daemon
```
**No authentication layer, no registry, no peer-to-peer.** Whoever can reach the
daemon's bind address can drive every session on the host — same trust model as
tmux itself. The operator picks the perimeter: `127.0.0.1` for local-only, a
WireGuard/LAN address to share with their own devices, never a public IP.
Every config field has a default, so `~/.config/maestro/config.toml` is
optional.
## Where the work is
Linear project **Maestro** under team **Naps62**. Issues `NAP-211..218` cover the prototype; each has `blockedBy` set so the dependency chain is explicit. **`NAP-211` is the only unblocked starting point.**
Linear project **Maestro** under team **Naps62**.
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from "available now" (status backlog/pending, not blocked).
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from
"available now" (status backlog/pending, not blocked).
## Repo layout
Cargo workspace. NAP-245 unified the per-binary crates behind a single
`crates/maestro/` umbrella that ships the `maestro` binary with feature gates
(`cli` / `daemon` / `registry`, default = all three). The library crates kept
their existing shape; only the per-binary `main.rs` files were retired.
(`cli` / `daemon` / `tui`, default = all three).
```
crates/
├── maestro/ # umbrella binary `maestro` (cli + daemon + registry, feature-gated) (NAP-245)
├── maestro/ # umbrella binary `maestro` (cli + daemon + tui, feature-gated) (NAP-245)
├── maestro-protocol/ # shared wire types (NAP-211)
├── maestro-config/ # shared on-disk config schema (NAP-233)
├── maestro-names/ # orchestra-themed session-handle generator (#23)
├── maestro-cli/ # `maestro list/spawn/attach/kill/policy` dispatch (NAP-216)
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + WS endpoints (NAP-212, NAP-214)
├── maestro-registry/ # `maestro registry` — phone book + HTTP API (NAP-213, NAP-215)
├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216)
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API (NAP-212, NAP-214)
├── maestro-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249)
├── maestro-tui/ # `maestro tui` — ratatui session manager (NAP-275 epic)
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
@@ -42,13 +65,5 @@ See `docs/INSTALL.md` for end-user install instructions.
- **TDD by default.** Failing test first, then minimal impl. Commit test + impl together.
- **Frequent commits.** One logical unit per commit; don't pile work up.
- **Conventional commits with `maestro` scope.** Example: `feat(maestro): add Hello message round-trip test`.
- **`cargo fmt` + `cargo clippy --all-targets -- -D warnings`** before every commit. (Add to CI as part of NAP-211.)
- **`cargo fmt` + `cargo clippy --all-targets -- -D warnings`** before every commit.
- **Integration tests use real `tmux` and `git`.** Don't mock them. Isolate via temp `$HOME` and `TMUX_TMPDIR`.
## Network assumption
Maestro is network-agnostic. The operator chooses bind addresses; the perimeter is whatever VPN (Wireguard, etc.) or private LAN they've set up. Maestro does not terminate TLS itself or police the network. See spec §Auth.
## Status
Empty repo. Spec approved and committed. **NAP-211 is the natural start.**
Generated
-164
View File
@@ -317,35 +317,6 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -437,15 +408,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -488,15 +450,6 @@ dependencies = [
"syn",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -1015,19 +968,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64",
"js-sys",
"ring",
"serde",
"serde_json",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1073,12 +1013,6 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1116,7 +1050,6 @@ dependencies = [
"clap",
"maestro-cli",
"maestro-daemon",
"maestro-registry",
"maestro-tui",
"tokio",
"tracing-subscriber",
@@ -1144,7 +1077,6 @@ dependencies = [
"toml",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
]
@@ -1168,11 +1100,9 @@ dependencies = [
"clap",
"dirs",
"futures-util",
"jsonwebtoken",
"libc",
"maestro-config",
"maestro-protocol",
"maestro-registry",
"reqwest",
"serde",
"serde_json",
@@ -1180,7 +1110,6 @@ dependencies = [
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite 0.21.0",
"toml",
"tracing",
"tracing-subscriber",
"urlencoding",
@@ -1217,13 +1146,6 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "maestro-names"
version = "0.1.0"
dependencies = [
"rand 0.8.6",
]
[[package]]
name = "maestro-protocol"
version = "0.1.0"
@@ -1234,31 +1156,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "maestro-registry"
version = "0.1.0"
dependencies = [
"axum",
"chrono",
"dirs",
"futures-util",
"jsonwebtoken",
"maestro-config",
"maestro-protocol",
"rand 0.8.6",
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite 0.21.0",
"toml",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "maestro-tui"
version = "0.1.0"
@@ -1373,12 +1270,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1456,12 +1347,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1490,22 +1375,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1707,8 +1576,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"cookie",
"cookie_store",
"futures-core",
"futures-util",
"http",
@@ -2165,37 +2032,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.3"
+1 -4
View File
@@ -16,16 +16,13 @@ clap = { version = "4.5", features = ["derive", "env"] }
crossterm = { version = "0.28", features = ["event-stream"] }
dirs = "5"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
jsonwebtoken = { version = "9", default-features = false }
libc = "0.2"
maestro-config = { path = "crates/maestro-config" }
maestro-names = { path = "crates/maestro-names" }
maestro-protocol = { path = "crates/maestro-protocol" }
maestro-tui = { path = "crates/maestro-tui" }
nix = { version = "0.29", default-features = false, features = ["term", "signal"] }
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "stream"] }
rust-embed = "8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
include_dir = "0.7"
mime_guess = "2"
bytes = "1"
+9 -10
View File
@@ -54,7 +54,7 @@ Tints/glow derive from the accent at low alpha: `--accent-tint` (0.12),
## Typography
**Monospace is the UI typeface** (brand identity), system-stack only (offline-
safe, zero web-font dependency, embedded clean into the registry binary):
safe, zero web-font dependency, embedded clean into the web binary):
- `--font-mono`: `ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", "Roboto Mono", Menlo, Consolas, monospace` — chrome, labels, data, ids, the wordmark.
- `--font-sans`: `system-ui` stack — reserved for the few prose strings (empty-state sentences).
@@ -86,9 +86,8 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
## Layout & structure
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
breakpoint stacks the header (full-width search, wrapped actions) and drops the
daemon chips to their own wrapped line. `≥600px` switches the spawn modal from
bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
breakpoint stacks the header (full-width search, wrapped actions). `≥600px`
switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
terminal atmosphere (very low contrast, not decoration).
## Components
@@ -96,10 +95,10 @@ terminal atmosphere (very low contrast, not decoration).
Three surfaces, all DOM built in TS, styled by class:
- **Catalog** — sticky blurred header (pulsing wordmark, search, theme toggle,
Spawn); status bar (`N sessions · M live` tally + daemon chips); session tree
(collapsible folders with counts, leaf rows with status dot, name, `@daemon`
+ command meta, exited badge, reveal-on-hover kill). Empty states are
distinct and teach: waiting-for-daemon, no-sessions, no-search-match.
Spawn); status bar (`N sessions · M live` tally); session tree (collapsible
directory folders with counts, leaf rows with status dot, name, command meta,
exited badge, reveal-on-hover kill). Empty states are distinct and teach:
no-sessions, no-search-match.
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
a framed dark xterm with a green cursor.
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
@@ -112,5 +111,5 @@ status — never decoration.
## Dev review aids (DEV-only, stripped from production)
`web/src/mock.ts` + query params seed a representative catalog without a running
registry: `?mock` (seed sessions/daemons), `?mock&spawn` (open the spawn
modal), `?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal),
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
-37
View File
@@ -1,37 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# Image for the Maestro registry.
#
# NAP-245 retired the per-binary crates: the registry now ships as the
# `registry` subcommand of the unified `maestro` binary. This image builds that
# binary with only the `registry` feature enabled (no cli/daemon/tui), so it
# stays small and pulls only maestro-registry + protocol + config.
#
# Build: docker build -t maestro-registry .
# Run: docker run --rm -p 8080:8080 -e MAESTRO_TOKEN=<secret> maestro-registry
#
# On Dokploy: point at this Dockerfile, expose port 8080 (or whatever you
# set MAESTRO_REGISTRY_BIND_ADDR to), and set MAESTRO_TOKEN as a secret env.
# No persistent volume is required when MAESTRO_TOKEN is provided.
FROM rust:1-bookworm AS builder
WORKDIR /build
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release -p maestro --no-default-features --features registry \
&& cp /build/target/release/maestro /maestro-registry
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /maestro-registry /usr/local/bin/maestro-registry
# Default to all-interfaces so Dokploy / Traefik can reach the service.
# Override via env on the platform.
ENV MAESTRO_REGISTRY_BIND_ADDR=0.0.0.0:8080
EXPOSE 8080
# The on-disk binary is the unified `maestro`; run its `registry` subcommand.
ENTRYPOINT ["/usr/local/bin/maestro-registry", "registry"]
+1 -1
View File
@@ -8,7 +8,7 @@ web:
# Run the web bundle in dev mode against a local daemon — Vite serves the
# frontend with HMR, and `/api/*` is proxied directly to the daemon at
# 127.0.0.1:7070 (or wherever $MAESTRO_REGISTRY points; see vite.config.ts).
# 127.0.0.1:7070 (see vite.config.ts).
web-dev:
pnpm -C web install
pnpm -C web dev
+12 -13
View File
@@ -7,22 +7,21 @@ product
## Users
A single technical operator (a developer) managing every interactive shell and
agent — Claude Code, opencode, plain bash — running across multiple machines.
The job-to-be-done: from any device (laptop, desktop, **phone**), get an
accurate at-a-glance picture of all live sessions across all hosts, then attach
to one and drive its terminal. Usage is often glance-and-go: checking on a
agent — Claude Code, opencode, plain bash — running on a machine. The
job-to-be-done: from any device (laptop, desktop, **phone**), get an accurate
at-a-glance picture of all live sessions on that host, then attach to one and
drive its terminal. Usage is often glance-and-go: checking on a
long-running agent, killing a stray session, or hopping into a shell mid-task.
Mobile-first is a hard requirement — touch targets, viewport-fit safe areas,
and bottom-sheet modals already reflect this.
## Product Purpose
Maestro is a unified terminal-session orchestrator: one place to see and attach
to every interactive session running across every machine the operator cares
about. tmux for persistence, WebSocket for transport, a daemon-per-host
federated by a small always-on registry. The web UI is the browser face of
that system — it renders the cross-host session catalog and provides the attach
terminal (xterm.js). Success = the operator trusts the picture is accurate and
Maestro is a terminal-session orchestrator: one place to see and attach to
every interactive session running on a machine. tmux for persistence,
WebSocket for transport, one daemon per host. The web UI is the browser face of
that system — it renders the session catalog and provides the attach terminal
(xterm.js). Success = the operator trusts the picture is accurate and
current, and can get from "open the page" to "typing in the right session" in
seconds, on any device.
@@ -51,14 +50,14 @@ efficient, calm / uncluttered, with earned moments of delight.
## Design Principles
1. **The scan is the product.** Optimize for instant comprehension of "what's
running, where, and is it healthy" across many sessions and hosts. Status
legibility beats everything else.
running, where, and is it healthy" across many sessions. Status legibility
beats everything else.
2. **Calm under load.** Stay uncluttered whether there are 3 sessions or 300;
hierarchy and grouping carry the weight, not color-shouting every row.
3. **Fast and keyboard-literate.** Low-friction, efficient, gets out of the
way; honor keyboard navigation, don't make the operator hunt.
4. **Monospace as identity.** Treat the mono/sans pairing as deliberate brand
typography, not just terminal output. Ids, hosts, counts, and state read as
typography, not just terminal output. Ids, paths, counts, and state read as
precise technical data.
5. **Earned delight.** Personality lives in motion and micro-detail (live-status
pulse, attach transitions, cursor cues), never in noise — always craft.
-1
View File
@@ -28,7 +28,6 @@ tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
urlencoding = "2"
uuid.workspace = true
[dev-dependencies]
+19 -95
View File
@@ -9,7 +9,7 @@ use std::os::fd::AsFd;
use std::sync::Arc;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{ClientControl, ConnectInfo};
use maestro_protocol::ClientControl;
use nix::sys::termios::{cfmakeraw, tcgetattr, tcsetattr, SetArg, Termios};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_tungstenite::tungstenite::Message;
@@ -34,11 +34,8 @@ pub enum AttachError {
impl AttachError {
/// True when the failure was an inability to reach the WS endpoint at the
/// transport level (connection refused). Used to fall back from the home
/// daemon broker to the legacy registry direct-attach path (NAP-244).
/// An HTTP-level rejection from a *reachable* daemon (e.g. the broker
/// returns 404 / 502) is deliberately NOT treated as unreachable — the
/// daemon answered, so we surface its error instead of masking it.
/// transport level (connection refused), as opposed to an HTTP-level
/// rejection from a daemon that did answer.
pub fn is_unreachable(&self) -> bool {
use std::io::ErrorKind;
use tokio_tungstenite::tungstenite::Error as WsError;
@@ -66,33 +63,14 @@ pub fn parse_detach_key(spec: &str) -> Result<u8, AttachError> {
}
}
/// Build the attach WS URL from a `ConnectInfo` and session ID.
#[allow(clippy::result_large_err)]
pub fn attach_url(info: &ConnectInfo, session_id: Uuid) -> Result<String, AttachError> {
let base = info.daemon_url.trim_end_matches('/');
if !(base.starts_with("ws://") || base.starts_with("wss://")) {
return Err(AttachError::InvalidDaemonUrl(info.daemon_url.clone()));
}
Ok(format!(
"{base}/sessions/{session_id}/stream?token={token}",
token = urlencoding::encode(&info.jwt)
))
}
/// Build the home-daemon broker attach WS URL from the daemon's HTTP base.
/// Build the attach WS URL from the daemon's HTTP base.
///
/// The broker endpoint (`WS /api/attach/:id`, NAP-243) lives on the daemon's
/// `bind_addr`, so we take the HTTP base (`http://127.0.0.1:7070`), swap the
/// scheme to its WS equivalent, and append the route. The daemon picks the
/// transport and dials the target (which verifies the registry-minted
/// pair-attach JWT — session binding, not a per-peer allow-list); the CLI just
/// pumps frames.
/// The endpoint (`WS /api/attach/:id`) lives on the daemon's `bind_addr`, so we
/// take the HTTP base (`http://127.0.0.1:7070`), swap the scheme to its WS
/// equivalent, and append the route.
#[allow(clippy::result_large_err)]
pub fn broker_attach_url(
home_daemon_http_base: &str,
session_id: Uuid,
) -> Result<String, AttachError> {
let base = home_daemon_http_base.trim_end_matches('/');
pub fn attach_url(daemon_http_base: &str, session_id: Uuid) -> Result<String, AttachError> {
let base = daemon_http_base.trim_end_matches('/');
let ws_base = if let Some(rest) = base.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = base.strip_prefix("http://") {
@@ -100,9 +78,7 @@ pub fn broker_attach_url(
} else if base.starts_with("ws://") || base.starts_with("wss://") {
base.to_string()
} else {
return Err(AttachError::InvalidDaemonUrl(
home_daemon_http_base.to_string(),
));
return Err(AttachError::InvalidDaemonUrl(daemon_http_base.to_string()));
};
Ok(format!("{ws_base}/api/attach/{session_id}"))
}
@@ -151,24 +127,9 @@ fn current_winsize() -> Option<(u16, u16)> {
Some((ws.ws_col, ws.ws_row))
}
/// Drive the attach loop until the client detaches or the remote closes.
///
/// `connect_info` is whatever `GET /api/sessions/:id/connect-info` returned;
/// `detach_byte` is the single byte that exits cleanly without killing the
/// session.
#[allow(clippy::result_large_err)]
pub async fn run_attach(
connect_info: ConnectInfo,
session_id: Uuid,
detach_byte: u8,
) -> Result<(), AttachError> {
let url = attach_url(&connect_info, session_id)?;
run_attach_url(&url, detach_byte).await
}
/// Drive the attach loop against an already-built WS `url`. Shared by the
/// home-daemon broker path ([`broker_attach_url`]) and the legacy registry
/// direct-attach path ([`run_attach`]) — both speak the same PTY wire format.
/// Drive the attach loop against an already-built WS `url` ([`attach_url`])
/// until the client detaches or the remote closes. `detach_byte` is the single
/// byte that exits cleanly without killing the session.
#[allow(clippy::result_large_err)]
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
@@ -273,7 +234,6 @@ pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachErro
#[cfg(test)]
mod tests {
use super::*;
use maestro_protocol::ConnectInfo;
use uuid::Uuid;
#[test]
@@ -297,64 +257,28 @@ mod tests {
}
#[test]
fn attach_url_uses_ws_path_and_token_query() {
let info = ConnectInfo {
daemon_url: "ws://10.0.0.1:7070".into(),
jwt: "abc.def-ghi_jkl".into(),
};
let id = Uuid::from_u128(0x1234_5678);
let url = attach_url(&info, id).unwrap();
assert!(url.starts_with("ws://10.0.0.1:7070/sessions/"));
assert!(url.contains(&id.to_string()));
assert!(url.contains("/stream?token=abc.def-ghi_jkl"));
}
#[test]
fn attach_url_rejects_non_ws_scheme() {
let info = ConnectInfo {
daemon_url: "http://x".into(),
jwt: "t".into(),
};
assert!(matches!(
attach_url(&info, Uuid::nil()),
Err(AttachError::InvalidDaemonUrl(_))
));
}
#[test]
fn broker_attach_url_swaps_scheme_and_appends_route() {
fn attach_url_swaps_scheme_and_appends_route() {
let id = Uuid::from_u128(0xabcd);
assert_eq!(
broker_attach_url("http://127.0.0.1:7070", id).unwrap(),
attach_url("http://127.0.0.1:7070", id).unwrap(),
format!("ws://127.0.0.1:7070/api/attach/{id}")
);
assert_eq!(
broker_attach_url("https://laptop.lan:7070/", id).unwrap(),
attach_url("https://laptop.lan:7070/", id).unwrap(),
format!("wss://laptop.lan:7070/api/attach/{id}")
);
// already-ws bases pass through
assert_eq!(
broker_attach_url("ws://10.0.0.1:7070", id).unwrap(),
attach_url("ws://10.0.0.1:7070", id).unwrap(),
format!("ws://10.0.0.1:7070/api/attach/{id}")
);
}
#[test]
fn broker_attach_url_rejects_unknown_scheme() {
fn attach_url_rejects_unknown_scheme() {
assert!(matches!(
broker_attach_url("ftp://x", Uuid::nil()),
attach_url("ftp://x", Uuid::nil()),
Err(AttachError::InvalidDaemonUrl(_))
));
}
#[test]
fn attach_url_trims_trailing_slash() {
let info = ConnectInfo {
daemon_url: "wss://reg.example.com/".into(),
jwt: "t".into(),
};
let id = Uuid::nil();
let url = attach_url(&info, id).unwrap();
assert!(!url.contains("//sessions"), "{url}");
}
}
+9 -30
View File
@@ -9,14 +9,15 @@ use uuid::Uuid;
#[command(
name = "maestro",
version,
about = "Control terminal sessions across one or more maestrod hosts",
about = "Control terminal sessions on the local maestro daemon",
arg_required_else_help = true
)]
pub struct Cli {
/// Override the registry URL for this invocation (e.g. `https://maestro.example.com`).
/// If omitted, `[shared].registry_url` from the unified config file is used.
#[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")]
pub registry_url: Option<String>,
/// Override the daemon HTTP base for this invocation (e.g.
/// `http://127.0.0.1:7070`). If omitted, `[cli].daemon_url` from the
/// unified config file is used.
#[arg(long, global = true, env = "MAESTRO_DAEMON_URL")]
pub daemon_url: Option<String>,
/// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`.
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
@@ -32,23 +33,18 @@ pub struct Cli {
#[derive(Debug, Subcommand)]
pub enum Command {
/// List all sessions, grouped by daemon.
/// List all sessions.
List(ListArgs),
/// Spawn a new session on a given daemon.
/// Spawn a new session.
Spawn(SpawnArgs),
/// Attach to a session over WebSocket. Bytes flow through the local TTY.
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// Set the default registry URL persisted in the CLI config.
SetRegistry(SetRegistryArgs),
}
#[derive(Debug, clap::Args)]
pub struct ListArgs {
/// Show only sessions on this daemon.
#[arg(long)]
pub daemon: Option<String>,
/// Output as JSON instead of a table.
#[arg(long)]
pub json: bool,
@@ -56,9 +52,6 @@ pub struct ListArgs {
#[derive(Debug, Clone, clap::Args)]
pub struct SpawnArgs {
/// Daemon ID to spawn on.
#[arg(long)]
pub daemon: String,
/// Working directory for the session (must exist on the daemon's host).
#[arg(long)]
pub cwd: PathBuf,
@@ -97,12 +90,6 @@ pub struct KillArgs {
pub session_id: Uuid,
}
#[derive(Debug, clap::Args)]
pub struct SetRegistryArgs {
/// Registry base URL to persist.
pub url: String,
}
fn parse_kv(s: &str) -> Result<(String, String), String> {
match s.split_once('=') {
Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())),
@@ -117,10 +104,9 @@ mod tests {
#[test]
fn parses_list() {
let cli = Cli::try_parse_from(["maestro", "list", "--daemon", "laptop", "--json"]).unwrap();
let cli = Cli::try_parse_from(["maestro", "list", "--json"]).unwrap();
match cli.command {
Command::List(args) => {
assert_eq!(args.daemon.as_deref(), Some("laptop"));
assert!(args.json);
}
_ => panic!("wrong subcommand"),
@@ -132,8 +118,6 @@ mod tests {
let cli = Cli::try_parse_from([
"maestro",
"spawn",
"--daemon",
"vm1",
"--cwd",
"/tmp",
"--command",
@@ -151,7 +135,6 @@ mod tests {
.unwrap();
match cli.command {
Command::Spawn(a) => {
assert_eq!(a.daemon, "vm1");
assert_eq!(a.command, "bash");
assert_eq!(a.branch.as_deref(), Some("feat/x"));
assert!(a.cleanup_on_kill);
@@ -169,8 +152,6 @@ mod tests {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--daemon",
"vm1",
"--cwd",
"/tmp",
"--command",
@@ -197,8 +178,6 @@ mod tests {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--daemon",
"x",
"--cwd",
"/",
"--command",
+28 -150
View File
@@ -1,8 +1,6 @@
//! CLI view over the unified Maestro config at `~/.config/maestro/config.toml`.
//!
//! Reads `[shared].registry_url` (registry HTTP base) and `[cli].cookie`
//! (persisted `maestro_session` JWT). All other sections in the file are
//! preserved on write.
//! Reads `[cli].daemon_url` and, as a fallback, `[daemon].bind_addr`.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
@@ -11,10 +9,10 @@ use maestro_config::MaestroConfigFile;
pub use maestro_config::CONFIG_PATH_ENV;
/// Last-resort home-daemon HTTP base when neither config nor a local
/// Last-resort daemon HTTP base when neither config nor a local
/// `[daemon].bind_addr` tells us otherwise. Matches the daemon's default
/// `bind_addr` (`crates/maestro-daemon` defaults).
pub const DEFAULT_HOME_DAEMON_URL: &str = "http://127.0.0.1:7070";
/// `bind_addr`.
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
/// `~/.config/maestro/config.toml` (XDG-aware).
pub fn default_config_path() -> Result<PathBuf, ConfigError> {
@@ -30,80 +28,46 @@ pub fn resolve_config_path() -> Result<PathBuf, ConfigError> {
pub enum ConfigError {
#[error(transparent)]
Inner(#[from] maestro_config::ConfigError),
#[error(
"no registry URL configured — pass --registry-url, set \
MAESTRO_REGISTRY_URL, or run `maestro set-registry <url>`"
)]
NoRegistryUrl,
}
/// Resolved CLI config — the subset of the unified file that this binary
/// actually consumes.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CliConfig {
pub registry_url: Option<String>,
pub cookie: Option<String>,
/// `[cli].home_daemon_url` — explicit home-daemon HTTP base (NAP-244).
pub home_daemon_url: Option<String>,
/// `[cli].daemon_url` — explicit daemon HTTP base.
pub daemon_url: Option<String>,
/// `[daemon].bind_addr` if a daemon is configured on this host. Used to
/// derive the default home-daemon URL when `home_daemon_url` is unset.
/// derive the default daemon URL when `daemon_url` is unset.
pub daemon_bind_addr: Option<SocketAddr>,
}
impl CliConfig {
/// Load the subset of the unified file the CLI consumes:
/// `[shared].registry_url`, `[cli].cookie`, `[cli].home_daemon_url`, and
/// `[daemon].bind_addr` (for default home-daemon derivation). Returns an
/// empty config if the file is missing — the CLI tolerates a missing file
/// so `maestro set-registry <url>` can bootstrap it.
/// Load the subset of the unified file the CLI consumes. A missing file is
/// fine — every field has a default.
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let file = MaestroConfigFile::load_or_default(path)?;
let file = MaestroConfigFile::load(path)?;
Ok(Self {
registry_url: file.shared.registry_url,
cookie: file.cli.cookie,
home_daemon_url: file.cli.home_daemon_url,
daemon_url: file.cli.daemon_url,
daemon_bind_addr: file.daemon.bind_addr,
})
}
/// Resolve the home-daemon HTTP base the broker path should target.
/// Resolve the daemon HTTP base to talk to.
///
/// Precedence: explicit `override_url` (CLI flag / env) → `[cli]
/// .home_daemon_url` → `http://<[daemon].bind_addr>` → the built-in
/// [`DEFAULT_HOME_DAEMON_URL`]. Always returns a value; reachability is
/// decided later by whether the connection actually succeeds.
pub fn resolve_home_daemon_url(&self, override_url: Option<String>) -> String {
/// Precedence: explicit `override_url` (CLI flag / env) → `[cli].daemon_url`
/// → `http://<[daemon].bind_addr>` → [`DEFAULT_DAEMON_URL`]. Always returns
/// a value; reachability is decided by whether the connection succeeds.
pub fn resolve_daemon_url(&self, override_url: Option<String>) -> String {
override_url
.or_else(|| self.home_daemon_url.clone())
.or_else(|| self.daemon_url.clone())
.or_else(|| self.daemon_bind_addr.map(|a| format!("http://{a}")))
.unwrap_or_else(|| DEFAULT_HOME_DAEMON_URL.to_string())
}
/// Persist `[shared].registry_url`. Read-modify-write — preserves
/// any other sections already in the file (`[daemon]`, `[registry]`,
/// existing `[cli].cookie`).
pub fn save_registry_url(path: &Path, url: &str) -> Result<(), ConfigError> {
let mut file = MaestroConfigFile::load_or_default(path)?;
file.shared.registry_url = Some(url.to_string());
file.save(path)?;
Ok(())
}
/// Persist `[cli].cookie`. Same read-modify-write semantics as
/// [`save_registry_url`]; in particular this does not touch
/// `[shared].registry_url`.
pub fn save_cookie(path: &Path, cookie: &str) -> Result<(), ConfigError> {
let mut file = MaestroConfigFile::load_or_default(path)?;
file.cli.cookie = Some(cookie.to_string());
file.save(path)?;
Ok(())
.unwrap_or_else(|| DEFAULT_DAEMON_URL.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use maestro_config::SharedSection;
fn write_file(path: &Path, body: &str) {
std::fs::write(path, body).unwrap();
@@ -117,134 +81,48 @@ mod tests {
}
#[test]
fn load_reads_shared_registry_url_and_cli_cookie() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_file(
&path,
r#"
[shared]
registry_url = "https://r.example"
[cli]
cookie = "eyJ..."
"#,
);
let cfg = CliConfig::load(&path).unwrap();
assert_eq!(cfg.registry_url.as_deref(), Some("https://r.example"));
assert_eq!(cfg.cookie.as_deref(), Some("eyJ..."));
}
#[test]
fn save_registry_url_preserves_other_sections() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_file(
&path,
r#"
[shared]
token = "abc"
[daemon]
daemon_url = "ws://laptop:7070"
"#,
);
CliConfig::save_registry_url(&path, "https://new.example/").unwrap();
let after = MaestroConfigFile::load(&path).unwrap();
assert_eq!(
after.shared.registry_url.as_deref(),
Some("https://new.example/")
);
assert_eq!(after.shared.token.as_deref(), Some("abc"));
assert_eq!(after.daemon.daemon_url.as_deref(), Some("ws://laptop:7070"));
}
#[test]
fn save_cookie_does_not_touch_registry_url() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_file(
&path,
r#"
[shared]
registry_url = "https://r.example"
"#,
);
CliConfig::save_cookie(&path, "eyJ.new").unwrap();
let after = MaestroConfigFile::load(&path).unwrap();
assert_eq!(
after.shared.registry_url.as_deref(),
Some("https://r.example")
);
assert_eq!(after.cli.cookie.as_deref(), Some("eyJ.new"));
}
#[test]
fn resolve_home_daemon_url_precedence() {
fn resolve_daemon_url_precedence() {
// override wins over everything
let cfg = CliConfig {
home_daemon_url: Some("http://from-cli-section:7070".into()),
daemon_url: Some("http://from-cli-section:7070".into()),
daemon_bind_addr: Some("127.0.0.1:9999".parse().unwrap()),
..Default::default()
};
assert_eq!(
cfg.resolve_home_daemon_url(Some("http://override:1".into())),
cfg.resolve_daemon_url(Some("http://override:1".into())),
"http://override:1"
);
// [cli].home_daemon_url next
assert_eq!(
cfg.resolve_home_daemon_url(None),
"http://from-cli-section:7070"
);
// [cli].daemon_url next
assert_eq!(cfg.resolve_daemon_url(None), "http://from-cli-section:7070");
// derive from [daemon].bind_addr when no explicit url
let cfg = CliConfig {
daemon_bind_addr: Some("10.0.0.7:7070".parse().unwrap()),
..Default::default()
};
assert_eq!(cfg.resolve_home_daemon_url(None), "http://10.0.0.7:7070");
assert_eq!(cfg.resolve_daemon_url(None), "http://10.0.0.7:7070");
// built-in default when nothing is configured
let cfg = CliConfig::default();
assert_eq!(cfg.resolve_home_daemon_url(None), DEFAULT_HOME_DAEMON_URL);
assert_eq!(cfg.resolve_daemon_url(None), DEFAULT_DAEMON_URL);
}
#[test]
fn load_reads_home_daemon_url_and_daemon_bind_addr() {
fn load_reads_daemon_url_and_daemon_bind_addr() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_file(
&path,
r#"
[cli]
home_daemon_url = "http://laptop:7070"
daemon_url = "http://laptop:7070"
[daemon]
bind_addr = "127.0.0.1:7071"
"#,
);
let cfg = CliConfig::load(&path).unwrap();
assert_eq!(cfg.home_daemon_url.as_deref(), Some("http://laptop:7070"));
assert_eq!(cfg.daemon_url.as_deref(), Some("http://laptop:7070"));
assert_eq!(
cfg.daemon_bind_addr,
Some("127.0.0.1:7071".parse().unwrap())
);
}
#[test]
fn save_creates_file_if_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested/config.toml");
CliConfig::save_registry_url(&path, "https://x").unwrap();
assert!(path.exists());
let after = MaestroConfigFile::load(&path).unwrap();
assert_eq!(
after,
MaestroConfigFile {
shared: SharedSection {
registry_url: Some("https://x".into()),
token: None,
},
..Default::default()
}
);
}
}
+35 -201
View File
@@ -1,33 +1,16 @@
//! Public dispatch for the `maestro` CLI subcommands.
//!
//! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
//! binary can dispatch to the same logic.
//!
//! ## Transport (NAP-244)
//!
//! The CLI talks to its **home daemon** (`[cli].home_daemon_url`, default
//! `http://127.0.0.1:7070`) and lets the daemon broker the rest:
//!
//! * `list` reads the daemon's local catalog mirror (own ∪ peer sessions).
//! * `spawn` / `kill` hit the daemon's local session endpoints.
//! * `attach` opens the daemon's `WS /api/attach/:id` broker, which picks the
//! transport and dials the target (the target verifies the registry-minted
//! pair-attach JWT — session binding, not a per-peer allow-list).
//!
//! If the home daemon is **unreachable** (connection refused / not running),
//! every command falls back to the legacy registry path (registry HTTP +
//! direct WS) with a one-line stderr warning, so migration is painless. As of
//! NAP-327 the daemon brokers cross-daemon create *and* kill over the registry
//! control link, so those no longer need a fallback — only an unreachable home
//! daemon does.
//! Every command talks to the local daemon (`[cli].daemon_url`, default
//! `http://127.0.0.1:7070`): `list` reads its session catalog, `spawn` / `kill`
//! hit its session endpoints, and `attach` opens `WS /api/attach/:id`.
use std::path::{Path, PathBuf};
use crate::attach::{broker_attach_url, parse_detach_key, run_attach, run_attach_url};
use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, SetRegistryArgs, SpawnArgs};
use crate::attach::{attach_url, parse_detach_key, run_attach_url};
use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, SpawnArgs};
use crate::config::{default_config_path, CliConfig, ConfigError};
use crate::http::{HttpError, RegistryClient};
use crate::sessions::{fetch_connect_info, run_kill, run_list, run_spawn};
use crate::http::DaemonClient;
use crate::sessions::{run_kill, run_list, run_spawn};
#[derive(Debug, thiserror::Error)]
#[allow(clippy::result_large_err)]
@@ -43,14 +26,12 @@ pub enum CliError {
}
/// Dispatch a parsed top-level subcommand. The umbrella binary passes the
/// global flags (`--config`, `--registry-url`, `--home-daemon-url`) in
/// alongside the variant.
/// global flags (`--config`, `--daemon-url`) in alongside the variant.
#[allow(clippy::result_large_err)]
pub async fn dispatch(
command: Command,
config: Option<PathBuf>,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
daemon_url_override: Option<String>,
) -> Result<(), CliError> {
let config_path = match config {
Some(p) => p,
@@ -58,116 +39,42 @@ pub async fn dispatch(
};
match command {
Command::SetRegistry(args) => set_registry(&config_path, args),
Command::List(args) => {
list(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
}
Command::Spawn(args) => {
spawn(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
}
Command::Attach(args) => {
attach(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
}
Command::Kill(args) => {
kill(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
}
Command::List(args) => list(&config_path, daemon_url_override, args).await,
Command::Spawn(args) => spawn(&config_path, daemon_url_override, args).await,
Command::Attach(args) => attach(&config_path, daemon_url_override, args).await,
Command::Kill(args) => kill(&config_path, daemon_url_override, args).await,
}
}
#[allow(clippy::result_large_err)]
fn set_registry(config_path: &Path, args: SetRegistryArgs) -> Result<(), CliError> {
let url = args.url.trim_end_matches('/');
CliConfig::save_registry_url(config_path, url)?;
println!("registry URL saved to {}", config_path.display());
Ok(())
}
#[allow(clippy::result_large_err)]
async fn list(
config_path: &Path,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
daemon_url_override: Option<String>,
args: ListArgs,
) -> Result<(), CliError> {
let cfg = CliConfig::load(config_path)?;
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override);
let daemon = daemon_client(&home_url, config_path);
let daemon = client(config_path, daemon_url_override)?;
let mut stdout = std::io::stdout().lock();
match run_list(&daemon, &args, &mut stdout).await {
Ok(()) => Ok(()),
Err(e) if e.is_unreachable() => {
warn_fallback(&home_url, &e);
let registry = registry_client(registry_url_override, &cfg, config_path)?;
run_list(&registry, &args, &mut stdout).await?;
Ok(())
}
Err(e) => Err(e.into()),
}
run_list(&daemon, &args, &mut stdout).await?;
Ok(())
}
#[allow(clippy::result_large_err)]
async fn spawn(
config_path: &Path,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
daemon_url_override: Option<String>,
args: SpawnArgs,
) -> Result<(), CliError> {
let cfg = CliConfig::load(config_path)?;
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override.clone());
let daemon = daemon_client(&home_url, config_path);
let daemon = client(config_path, daemon_url_override)?;
let attach_after = args.attach;
// `spawn --attach` has no detach-key flag; use the attach default (Ctrl-q).
let detach_key = 0x11;
// The home daemon now brokers cross-daemon create over the registry
// control link (NAP-327), so the only reason to fall back to the legacy
// registry HTTP path is the home daemon being unreachable.
let session = match run_spawn(&daemon, args.clone()).await {
Ok(s) => s,
Err(e) if e.is_unreachable() => {
warn_fallback(&home_url, &e);
let registry = registry_client(registry_url_override.clone(), &cfg, config_path)?;
run_spawn(&registry, args).await?
}
Err(e) => return Err(e.into()),
};
let session = run_spawn(&daemon, args).await?;
println!("spawned {}", session.id);
if attach_after {
attach_session(
&home_url,
registry_url_override,
&cfg,
config_path,
session.id,
detach_key,
)
.await?;
let url = attach_url(daemon.base(), session.id)?;
run_attach_url(&url, detach_key).await?;
}
Ok(())
}
@@ -175,105 +82,32 @@ async fn spawn(
#[allow(clippy::result_large_err)]
async fn attach(
config_path: &Path,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
daemon_url_override: Option<String>,
args: AttachArgs,
) -> Result<(), CliError> {
let cfg = CliConfig::load(config_path)?;
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override);
let daemon = client(config_path, daemon_url_override)?;
let detach = parse_detach_key(&args.detach_key)?;
attach_session(
&home_url,
registry_url_override,
&cfg,
config_path,
args.session_id,
detach,
)
.await
let url = attach_url(daemon.base(), args.session_id)?;
run_attach_url(&url, detach).await?;
Ok(())
}
#[allow(clippy::result_large_err)]
async fn kill(
config_path: &Path,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
daemon_url_override: Option<String>,
args: KillArgs,
) -> Result<(), CliError> {
let cfg = CliConfig::load(config_path)?;
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override);
let daemon = daemon_client(&home_url, config_path);
// The home daemon brokers cross-daemon kills too (NAP-327) and 404s only on
// a session no daemon knows about, so we no longer fall back on 404 — only
// when the home daemon itself is unreachable.
match run_kill(&daemon, &args).await {
Ok(()) => {}
Err(e) if e.is_unreachable() => {
warn_fallback(&home_url, &e);
let registry = registry_client(registry_url_override, &cfg, config_path)?;
run_kill(&registry, &args).await?;
}
Err(e) => return Err(e.into()),
}
let daemon = client(config_path, daemon_url_override)?;
run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id);
Ok(())
}
/// Attach to `session_id` through the home-daemon broker, falling back to the
/// registry's connect-info + direct WS path if the daemon is unreachable.
/// HTTP client pointed at the daemon's API. That surface needs no auth —
/// reaching `bind_addr` is the authorization.
#[allow(clippy::result_large_err)]
async fn attach_session(
home_url: &str,
registry_url_override: Option<String>,
cfg: &CliConfig,
config_path: &Path,
session_id: uuid::Uuid,
detach: u8,
) -> Result<(), CliError> {
let broker_url = broker_attach_url(home_url, session_id)?;
match run_attach_url(&broker_url, detach).await {
Ok(()) => Ok(()),
Err(e) if e.is_unreachable() => {
eprintln!(
"[maestro] home daemon at {home_url} unreachable; \
falling back to the registry (legacy direct attach)"
);
let registry = registry_client(registry_url_override, cfg, config_path)?;
let info = fetch_connect_info(&registry, session_id).await?;
run_attach(info, session_id, detach).await?;
Ok(())
}
Err(e) => Err(e.into()),
}
}
/// HTTP client pointed at the home daemon's local API. The daemon's local
/// surface needs no auth (same-trust as `bind_addr`), so no cookie is sent.
fn daemon_client(home_url: &str, config_path: &Path) -> RegistryClient {
RegistryClient::new(home_url.to_string(), None, config_path.to_path_buf())
}
/// HTTP client pointed at the registry (legacy / fallback path).
#[allow(clippy::result_large_err)]
fn registry_client(
override_url: Option<String>,
cfg: &CliConfig,
config_path: &Path,
) -> Result<RegistryClient, CliError> {
let url = override_url
.or_else(|| cfg.registry_url.clone())
.ok_or(ConfigError::NoRegistryUrl)?;
Ok(RegistryClient::new(
url,
cfg.cookie.clone(),
config_path.to_path_buf(),
))
}
fn warn_fallback(home_url: &str, err: &HttpError) {
eprintln!(
"[maestro] home daemon at {home_url} unavailable ({err}); \
falling back to the registry"
);
fn client(config_path: &Path, override_url: Option<String>) -> Result<DaemonClient, CliError> {
let cfg = CliConfig::load(config_path)?;
Ok(DaemonClient::new(cfg.resolve_daemon_url(override_url)))
}
+13 -64
View File
@@ -1,19 +1,14 @@
//! Thin HTTP client wrapper around `reqwest` that persists the
//! `maestro_session` cookie issued by the registry's cookie middleware.
//! Thin HTTP client wrapper around `reqwest` for the daemon's `/api/*` surface.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use maestro_protocol::{extract_cookie, COOKIE_NAME};
use reqwest::header::{HeaderMap, HeaderValue, COOKIE, SET_COOKIE};
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::config::{CliConfig, ConfigError};
use crate::config::ConfigError;
/// Time budget for establishing a TCP+TLS connection to the registry/daemon.
/// Time budget for establishing a TCP connection to the daemon.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Total time budget for a single request (connect + send + receive). Bounds
/// the case where the peer is TCP-alive but never makes forward progress.
@@ -23,7 +18,7 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
///
/// Centralizing construction here guarantees every client the CLI uses gets a
/// connect *and* request timeout, so `maestro` never hangs forever on an
/// unhealthy registry or daemon (NAP-262).
/// unhealthy daemon (NAP-262).
pub fn build_client() -> reqwest::Client {
build_client_with_timeouts(CONNECT_TIMEOUT, REQUEST_TIMEOUT)
}
@@ -47,41 +42,30 @@ pub enum HttpError {
Json(#[from] serde_json::Error),
#[error("config: {0}")]
Config(#[from] ConfigError),
#[error("invalid header: {0}")]
InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
}
impl HttpError {
/// True when the error is a transport-level failure to reach the host
/// (connection refused / connect error) rather than an HTTP-level
/// rejection. The CLI uses this to decide whether to fall back from the
/// home daemon to the legacy registry path (NAP-244).
/// rejection.
pub fn is_unreachable(&self) -> bool {
matches!(self, HttpError::Reqwest(e) if e.is_connect())
}
}
/// HTTP client bound to a single registry. Persists the auth cookie back to
/// disk when the registry sets one (first-contact bootstrap from
/// `crates/maestro-registry/src/auth.rs`).
/// HTTP client bound to one daemon's `/api/*` base URL. The daemon's surface
/// needs no auth — reaching its bind address is the authorization.
#[derive(Debug)]
pub struct RegistryClient {
pub struct DaemonClient {
base: String,
inner: reqwest::Client,
/// Cookie value (the JWT body), shared with the config-persister.
cookie: Arc<Mutex<Option<String>>>,
/// Where to write the config back when the cookie changes.
config_path: PathBuf,
}
impl RegistryClient {
pub fn new(base: String, cookie: Option<String>, config_path: PathBuf) -> Self {
let inner = build_client();
impl DaemonClient {
pub fn new(base: String) -> Self {
Self {
base: base.trim_end_matches('/').to_string(),
inner,
cookie: Arc::new(Mutex::new(cookie)),
config_path,
inner: build_client(),
}
}
@@ -89,13 +73,6 @@ impl RegistryClient {
&self.base
}
/// Snapshot of the current cookie (mostly for tests).
pub fn current_cookie(&self) -> Option<String> {
self.cookie.lock().unwrap().clone()
}
/// Send a request, attaching the persisted cookie. If the response sets a
/// new cookie (first contact, or a rotation), persist it back to disk.
async fn send(
&self,
method: Method,
@@ -103,39 +80,11 @@ impl RegistryClient {
body: Option<serde_json::Value>,
) -> Result<Response, HttpError> {
let url = format!("{}{}", self.base, path);
let mut headers = HeaderMap::new();
if let Some(c) = self.cookie.lock().unwrap().as_ref() {
headers.insert(
COOKIE,
HeaderValue::from_str(&format!("{COOKIE_NAME}={c}"))?,
);
}
let mut req = self.inner.request(method, &url).headers(headers);
let mut req = self.inner.request(method, &url);
if let Some(b) = body {
req = req.json(&b);
}
let resp = req.send().await?;
self.capture_cookie(resp.headers())?;
Ok(resp)
}
fn capture_cookie(&self, headers: &HeaderMap) -> Result<(), HttpError> {
let Some(raw) = headers.get(SET_COOKIE).and_then(|h| h.to_str().ok()) else {
return Ok(());
};
let Some(val) = extract_cookie(raw, COOKIE_NAME) else {
return Ok(());
};
let mut guard = self.cookie.lock().unwrap();
if guard.as_deref() == Some(val) {
return Ok(());
}
*guard = Some(val.to_string());
// Persist; on failure, log but don't abort the request.
if let Err(e) = CliConfig::save_cookie(&self.config_path, val) {
tracing::warn!(error = %e, path = %self.config_path.display(), "failed to persist cookie");
}
Ok(())
Ok(req.send().await?)
}
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
+1 -4
View File
@@ -1,7 +1,4 @@
//! `maestro` CLI — thin client for the registry/daemon API.
//!
//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §"Wire protocol"
//! and the registry's `client_api.rs` for the HTTP surface this client targets.
//! `maestro` CLI — thin client for the local daemon's `/api/*` surface.
#![deny(missing_debug_implementations)]
+78 -153
View File
@@ -1,45 +1,46 @@
//! `maestro list / spawn / kill` — registry HTTP calls + console formatting.
//! `maestro list / spawn / kill` — daemon HTTP calls + console formatting.
use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::{
ActivityState, ConnectInfo, CreateSessionRequest, DaemonInfo, Session, SessionStatus,
WorktreeSpec,
};
use maestro_protocol::{ActivityState, CreateSessionRequest, Session, SessionStatus, WorktreeSpec};
use serde::Deserialize;
use serde_json::Value;
use uuid::Uuid;
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
use crate::http::{HttpError, RegistryClient};
use crate::http::{DaemonClient, HttpError};
/// The bits of `GET /api/info` the CLI renders. Deserialized structurally so
/// the CLI doesn't depend on the daemon crate.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DaemonInfo {
#[serde(default)]
pub hostname: String,
#[serde(default)]
pub home_dir: String,
}
/// `maestro list`
pub async fn run_list(
client: &RegistryClient,
client: &DaemonClient,
args: &ListArgs,
out: &mut impl Write,
) -> Result<(), HttpError> {
let mut sessions: Vec<Session> = client.get_json("/api/sessions").await?;
let daemons: Vec<DaemonInfo> = client
.get_json::<Vec<DaemonInfo>>("/api/daemons")
.await
.unwrap_or_default();
if let Some(d) = &args.daemon {
sessions.retain(|s| &s.daemon_id == d);
}
let sessions: Vec<Session> = client.get_json("/api/sessions").await?;
let info: DaemonInfo = client.get_json("/api/info").await.unwrap_or_default();
if args.json {
let body = serde_json::json!({ "sessions": sessions, "daemons": daemons });
let body = serde_json::json!({ "sessions": sessions });
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
return Ok(());
}
print_session_table(out, &sessions, &daemons);
print_session_table(out, &sessions, &info);
Ok(())
}
/// `maestro spawn`
pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result<Session, HttpError> {
pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session, HttpError> {
let mut env: HashMap<String, String> = HashMap::new();
for (k, v) in args.env.iter() {
env.insert(k.clone(), v.clone());
@@ -57,7 +58,6 @@ pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result<Sessi
};
let body = CreateSessionRequest {
daemon_id: args.daemon.clone(),
cwd: args.cwd.clone(),
command: args.command.clone(),
env,
@@ -69,88 +69,55 @@ pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result<Sessi
}
/// `maestro kill`
pub async fn run_kill(client: &RegistryClient, args: &KillArgs) -> Result<(), HttpError> {
pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), HttpError> {
client
.delete(&format!("/api/sessions/{}", args.session_id))
.await
}
/// `GET /api/sessions/:id/connect-info`
pub async fn fetch_connect_info(
client: &RegistryClient,
session_id: Uuid,
) -> Result<ConnectInfo, HttpError> {
client
.get_json(&format!("/api/sessions/{session_id}/connect-info"))
.await
}
#[allow(clippy::write_literal)]
fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[DaemonInfo]) {
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
if sessions.is_empty() {
if daemons.is_empty() {
writeln!(out, "no sessions, no daemons connected").ok();
} else {
writeln!(
out,
"no sessions ({} daemon{} connected)",
daemons.len(),
if daemons.len() == 1 { "" } else { "s" }
)
.ok();
}
writeln!(out, "no sessions").ok();
return;
}
// Group by daemon, stable order.
let mut by_daemon: Vec<(String, Vec<&Session>)> = Vec::new();
for s in sessions {
if let Some(slot) = by_daemon.iter_mut().find(|(d, _)| d == &s.daemon_id) {
slot.1.push(s);
} else {
by_daemon.push((s.daemon_id.clone(), vec![s]));
}
}
for (_, group) in &mut by_daemon {
group.sort_by_key(|s| s.created_at);
}
let mut sorted: Vec<&Session> = sessions.iter().collect();
sorted.sort_by_key(|s| s.created_at);
for (daemon_id, group) in &by_daemon {
let daemon = daemons.iter().find(|d| &d.daemon_id == daemon_id);
let hostname = daemon.map(|d| d.hostname.as_str()).unwrap_or("?");
let home = daemon.map(|d| d.home_dir.as_str()).unwrap_or("");
writeln!(out, "\n{daemon_id} ({hostname})").ok();
if !info.hostname.is_empty() {
writeln!(out, "\n{}", info.hostname).ok();
}
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
)
.ok();
for s in sorted {
// The pane's live cwd if observed, else the spawn dir, home-abbreviated.
let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), &info.home_dir);
let status = match s.status {
SessionStatus::Running => "running",
SessionStatus::Dead => "dead",
};
let activity = match s.activity {
ActivityState::Active => "active",
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
let command = match &s.foreground {
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
_ => s.command.clone(),
};
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
" {:<36} {:<8} {:<9} {:<28} {command}",
s.id, status, activity, dir
)
.ok();
for s in group {
// The pane's live cwd if observed, else the spawn dir, home-abbreviated.
let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), home);
let status = match s.status {
SessionStatus::Running => "running",
SessionStatus::Dead => "dead",
};
let activity = match s.activity {
ActivityState::Active => "active",
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
let command = match &s.foreground {
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
_ => s.command.clone(),
};
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {command}",
s.id, status, activity, dir
)
.ok();
}
}
}
@@ -175,13 +142,14 @@ mod tests {
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{ActivityState, DaemonInfo, Session, SessionStatus};
use maestro_protocol::{ActivityState, Session, SessionStatus};
use uuid::Uuid;
fn session(daemon: &str, cwd: &str, command: &str, id: u128) -> Session {
use super::DaemonInfo;
fn session(cwd: &str, command: &str, id: u128) -> Session {
Session {
id: Uuid::from_u128(id),
daemon_id: daemon.into(),
cwd: PathBuf::from(cwd),
command: command.into(),
env: HashMap::new(),
@@ -195,37 +163,26 @@ mod tests {
}
}
fn info(home: &str) -> DaemonInfo {
DaemonInfo {
hostname: "naps-laptop".into(),
home_dir: home.into(),
}
}
#[test]
fn formats_grouped_table() {
fn formats_table() {
let sessions = vec![
session("laptop", "/home/naps/maestro", "claude", 1),
session("vm1", "/srv/work/api", "bash", 2),
session("laptop", "/home/naps/scratch", "nvim", 3),
];
let daemons = vec![
DaemonInfo {
daemon_id: "laptop".into(),
hostname: "naps-laptop".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.1:7070".into(),
home_dir: "/home/naps".into(),
},
DaemonInfo {
daemon_id: "vm1".into(),
hostname: "vm1.lan".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(),
home_dir: String::new(),
},
session("/home/naps/maestro", "claude", 1),
session("/srv/work/api", "bash", 2),
];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &sessions, &daemons);
super::print_session_table(&mut buf, &sessions, &info("/home/naps"));
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("laptop (naps-laptop)"), "{s}");
assert!(s.contains("vm1 (vm1.lan)"), "{s}");
assert!(s.contains("naps-laptop"), "{s}");
// Home dir collapses to ~ in the DIR column.
assert!(s.contains("~/maestro"), "{s}");
// No home dir → path shown verbatim.
// Outside the home dir → path shown verbatim.
assert!(s.contains("/srv/work/api"), "{s}");
assert!(s.contains("running"), "{s}");
assert!(s.contains("ACTIVITY"), "{s}");
@@ -235,35 +192,21 @@ mod tests {
#[test]
fn live_cwd_overrides_spawn_dir_in_table() {
let mut s = session("laptop", "/home/naps/maestro", "bash", 1);
let mut s = session("/home/naps/maestro", "bash", 1);
s.current_cwd = Some(PathBuf::from("/home/naps/maestro/crates/maestro-cli"));
let daemons = vec![DaemonInfo {
daemon_id: "laptop".into(),
hostname: "h".into(),
version: "0.1.0".into(),
daemon_url: "ws://x".into(),
home_dir: "/home/naps".into(),
}];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[s], &daemons);
super::print_session_table(&mut buf, &[s], &info("/home/naps"));
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}");
}
#[test]
fn shows_activity_and_foreground() {
let mut s1 = session("laptop", "/tmp", "bash", 1);
let mut s1 = session("/tmp", "bash", 1);
s1.activity = ActivityState::AwaitingInput;
s1.foreground = Some("claude".into()); // differs from spawn command
let daemons = vec![DaemonInfo {
daemon_id: "laptop".into(),
hostname: "h".into(),
version: "0.1.0".into(),
daemon_url: "ws://x".into(),
home_dir: String::new(),
}];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[s1], &daemons);
super::print_session_table(&mut buf, &[s1], &info(""));
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("awaiting"), "{out}");
// Live foreground surfaces next to the spawn command.
@@ -271,27 +214,9 @@ mod tests {
}
#[test]
fn empty_sessions_with_no_daemons() {
fn empty_sessions() {
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[], &[]);
assert_eq!(
String::from_utf8(buf).unwrap(),
"no sessions, no daemons connected\n"
);
}
#[test]
fn empty_sessions_with_daemons() {
let daemons = vec![DaemonInfo {
daemon_id: "laptop".into(),
hostname: "naps".into(),
version: "0.1.0".into(),
daemon_url: "ws://x".into(),
home_dir: String::new(),
}];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[], &daemons);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("1 daemon connected"), "{s}");
super::print_session_table(&mut buf, &[], &info(""));
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
}
}
+41 -227
View File
@@ -4,28 +4,19 @@
//! `$MAESTRO_CONFIG`) holds settings for every Maestro binary:
//!
//! ```toml
//! [shared]
//! registry_url = "https://maestro.example.com"
//! token = "<shared secret>"
//!
//! [cli]
//! cookie = "<jwt cookie>"
//! daemon_url = "http://127.0.0.1:7070"
//!
//! [daemon]
//! daemon_url = "ws://laptop.lan:7070"
//! bind_addr = "127.0.0.1:7070"
//! bind_addr = "127.0.0.1:7070"
//!
//! [registry]
//! [web]
//! bind_addr = "127.0.0.1:8080"
//! ```
//!
//! The CLI consumes `[shared].registry_url` as its HTTP base; the daemon
//! derives its WS endpoint with [`derive_daemon_ws_url`] (scheme swap +
//! `/daemon` suffix).
//!
//! This crate owns the file schema, path resolution, atomic save, and the
//! URL derivation helper. Each binary applies its own env-var precedence and
//! defaults on top of [`MaestroConfigFile`].
//! This crate owns the file schema, path resolution, and atomic save. Each
//! binary applies its own env-var precedence and defaults on top of
//! [`MaestroConfigFile`].
use std::collections::BTreeMap;
use std::net::SocketAddr;
@@ -50,16 +41,6 @@ pub enum ConfigError {
TomlEncode(#[from] toml::ser::Error),
#[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")]
NoConfigDir,
#[error(
"maestro config not found at {}\n\
Run `maestrod configure` to create it. \
(Legacy ~/.config/maestrod/config.toml and ~/.config/maestro/{{cli,registry}}.toml \
are no longer read.)",
path.display()
)]
MissingFile { path: PathBuf },
#[error("invalid registry_url {input:?}: {reason}")]
InvalidRegistryUrl { input: String, reason: String },
#[error("invalid {env} ({input:?}): {source}")]
InvalidBindAddr {
env: &'static str,
@@ -69,18 +50,13 @@ pub enum ConfigError {
}
/// On-disk representation of the unified config. Every section is optional
/// so a partial file (e.g. just `[shared]` + `[registry]` for a registry
/// host) is valid.
/// so a partial file (e.g. just `[daemon]`) is valid.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MaestroConfigFile {
#[serde(default, skip_serializing_if = "SharedSection::is_empty")]
pub shared: SharedSection,
#[serde(default, skip_serializing_if = "CliSection::is_empty")]
pub cli: CliSection,
#[serde(default, skip_serializing_if = "DaemonSection::is_empty")]
pub daemon: DaemonSection,
#[serde(default, skip_serializing_if = "RegistrySection::is_empty")]
pub registry: RegistrySection,
#[serde(default, skip_serializing_if = "WebSection::is_empty")]
pub web: WebSection,
#[serde(default, skip_serializing_if = "TuiSection::is_empty")]
@@ -89,57 +65,28 @@ pub struct MaestroConfigFile {
pub spawn: SpawnSection,
}
/// Fields consumed by more than one binary (CLI + daemon, daemon + registry).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SharedSection {
/// Registry base URL, e.g. `https://maestro.example.com`. CLI uses it
/// as-is for HTTP; the daemon derives its WS endpoint via
/// [`derive_daemon_ws_url`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_url: Option<String>,
/// Shared HS256 secret. Registry signs JWTs with it, daemons verify
/// (and authenticate registry calls) with it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
impl SharedSection {
fn is_empty(&self) -> bool {
self.registry_url.is_none() && self.token.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CliSection {
/// Persisted `maestro_session` cookie value (a JWT). Opaque to the CLI.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cookie: Option<String>,
/// HTTP base URL of the home daemon the CLI brokers through (NAP-244),
/// e.g. `http://127.0.0.1:7070`. When unset, the CLI derives it from
/// HTTP base URL of the daemon the CLI talks to, e.g.
/// `http://127.0.0.1:7070`. When unset, the CLI derives it from
/// `[daemon].bind_addr` if a daemon is configured locally, else falls
/// back to `http://127.0.0.1:7070`. The legacy registry path stays
/// available as a compat fallback via `[shared].registry_url`.
/// back to `http://127.0.0.1:7070`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub home_daemon_url: Option<String>,
pub daemon_url: Option<String>,
}
impl CliSection {
fn is_empty(&self) -> bool {
self.cookie.is_none() && self.home_daemon_url.is_none()
self.daemon_url.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_id: Option<String>,
/// Hostname advertised to clients, purely for display. Defaults to
/// `gethostname(2)`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
/// Reachable URL clients dial for the daemon's per-session WS endpoint.
/// Distinct from `bind_addr` because the bind may be `0.0.0.0` while
/// the publicly reachable address is a Wireguard / VPN interface.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bind_addr: Option<SocketAddr>,
/// Root for everything daemon-local: sidecars, FIFOs, etc.
@@ -149,16 +96,6 @@ pub struct DaemonSection {
/// with a `worktree` spec.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_root: Option<PathBuf>,
/// Operator-set perimeter id (e.g. `"wg-naps62"`). When two daemons share
/// a perimeter, peer-to-peer attach can use the T2 transport. Advertised
/// to the registry in heartbeats. See connection-orchestration spec.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perimeter_id: Option<String>,
/// Daemon's reachable address on the declared perimeter (e.g.
/// `"10.0.0.7:7070"`). Advertised in heartbeats; the registry hands it
/// back to peer daemons as a T2 dial target.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub private_addr: Option<String>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
/// receives this much of the prior PTY output before the live stream.
/// `None` ⇒ daemon default (~64 KB); `Some(0)` disables replay.
@@ -181,38 +118,22 @@ pub struct DaemonSection {
impl DaemonSection {
fn is_empty(&self) -> bool {
self.daemon_id.is_none()
&& self.hostname.is_none()
&& self.daemon_url.is_none()
self.hostname.is_none()
&& self.bind_addr.is_none()
&& self.state_dir.is_none()
&& self.worktree_root.is_none()
&& self.perimeter_id.is_none()
&& self.private_addr.is_none()
&& self.pty_replay_bytes.is_none()
&& self.tmux_tmpdir.is_none()
&& self.spawn_history_days.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistrySection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bind_addr: Option<SocketAddr>,
}
impl RegistrySection {
fn is_empty(&self) -> bool {
self.bind_addr.is_none()
}
}
/// `[web]` — `maestro-web` binary settings (NAP-241).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebSection {
/// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a
/// perimeter-facing address (LAN/WG) to share with other devices on a
/// trusted segment; do NOT bind a public address without adding auth.
/// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a LAN/VPN
/// address to share with your own devices on a trusted segment; there is no
/// auth layer, so never bind a public address.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bind_addr: Option<SocketAddr>,
/// HTTP base URL of the co-located daemon `maestro-web` proxies to.
@@ -387,28 +308,17 @@ fn validate_presets(presets: &[SpawnPreset]) -> Result<(), SpawnConfigError> {
}
impl MaestroConfigFile {
/// Strict load — error with [`ConfigError::MissingFile`] if absent.
/// Load the file, or return an empty config if it doesn't exist. Every
/// field has a default, so a missing file is never an error.
pub fn load(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Err(ConfigError::MissingFile {
path: path.to_path_buf(),
});
return Ok(Self::default());
}
let bytes = std::fs::read(path)?;
let s = std::str::from_utf8(&bytes).unwrap_or("");
Ok(toml::from_str(s)?)
}
/// Load if present, else return an empty config. Use for callers that
/// want a default-on-missing read (e.g. CLI's `set-registry` bootstrap,
/// registry's first-run token generation).
pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
Self::load(path)
}
/// Atomic write (tmp + rename). Creates parent dirs on demand.
pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
@@ -436,34 +346,10 @@ pub fn default_config_path() -> Result<PathBuf, ConfigError> {
Ok(base.join(DIR_NAME).join(FILE_NAME))
}
/// Derive the daemon's WS endpoint from the shared `registry_url`.
///
/// | input | output |
/// |--------------------------|---------------------------------|
/// | `https://r.example` | `wss://r.example/daemon` |
/// | `http://r.example:8080` | `ws://r.example:8080/daemon` |
/// | `http://r/some/prefix/` | `ws://r/some/prefix/daemon` |
///
/// Trailing slashes are normalised so the result never has `//daemon`.
/// Any non-`http(s)` scheme returns [`ConfigError::InvalidRegistryUrl`].
pub fn derive_daemon_ws_url(registry_url: &str) -> Result<String, ConfigError> {
let trimmed = registry_url.trim_end_matches('/');
if let Some(rest) = trimmed.strip_prefix("https://") {
Ok(format!("wss://{rest}/daemon"))
} else if let Some(rest) = trimmed.strip_prefix("http://") {
Ok(format!("ws://{rest}/daemon"))
} else {
Err(ConfigError::InvalidRegistryUrl {
input: registry_url.to_string(),
reason: "must start with http:// or https://".into(),
})
}
}
/// Parse a `SocketAddr` from a raw (usually env-sourced) string, tagging the
/// failure with the originating env-var name so every binary surfaces the same
/// `invalid <ENV> (...)` diagnostic. Shared by the daemon, registry, and web
/// `bind_addr` resolution paths (NAP-273).
/// `invalid <ENV> (...)` diagnostic. Shared by the daemon and web `bind_addr`
/// resolution paths (NAP-273).
pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigError> {
raw.parse().map_err(|source| ConfigError::InvalidBindAddr {
env,
@@ -483,13 +369,13 @@ pub fn default_state_dir(subdir: &str) -> Result<PathBuf, ConfigError> {
Ok(base.join(subdir))
}
/// Capacities for the daemon/registry broadcast channels (session events and
/// PTY byte fan-out). These were four independent `= 256` constants scattered
/// across the daemon and registry; centralizing them here makes the coincidence
/// explicit and gives a single home for per-deployment tuning later (NAP-273).
/// Capacities for the daemon's broadcast channels (session events and PTY byte
/// fan-out). These were independent `= 256` constants scattered across the
/// daemon; centralizing them here makes the coincidence explicit and gives a
/// single home for per-deployment tuning later (NAP-273).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BroadcastTuning {
/// Session/registry event channels (`SessionEvent`, `RegistryEvent`).
/// Session event channels (`SessionEvent`, `CatalogEvent`).
pub events_capacity: usize,
/// Per-session PTY byte broadcast channel.
pub pty_capacity: usize,
@@ -545,21 +431,10 @@ mod tests {
}
#[test]
fn missing_file_strict_load_errors_with_pointer_to_configure() {
fn missing_file_loads_as_empty_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let err = MaestroConfigFile::load(&path).unwrap_err();
let msg = err.to_string();
assert!(matches!(err, ConfigError::MissingFile { .. }));
assert!(msg.contains("maestrod configure"), "msg was: {msg}");
assert!(msg.contains("Legacy"), "msg was: {msg}");
}
#[test]
fn missing_file_load_or_default_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = MaestroConfigFile::load_or_default(&path).unwrap();
let cfg = MaestroConfigFile::load(&path).unwrap();
assert_eq!(cfg, MaestroConfigFile::default());
}
@@ -568,30 +443,18 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = MaestroConfigFile {
shared: SharedSection {
registry_url: Some("https://r.example".into()),
token: Some("secret".into()),
},
cli: CliSection {
cookie: Some("eyJ...".into()),
home_daemon_url: Some("http://127.0.0.1:7070".into()),
daemon_url: Some("http://127.0.0.1:7070".into()),
},
daemon: DaemonSection {
daemon_id: Some("laptop".into()),
hostname: Some("laptop.lan".into()),
daemon_url: Some("ws://laptop.lan:7070".into()),
bind_addr: Some("127.0.0.1:7070".parse().unwrap()),
state_dir: Some(PathBuf::from("/var/lib/maestrod")),
worktree_root: Some(PathBuf::from("/var/lib/maestrod/wt")),
perimeter_id: Some("wg-naps62".into()),
private_addr: Some("10.0.0.7:7070".into()),
pty_replay_bytes: Some(131_072),
tmux_tmpdir: Some(PathBuf::from("/run/user/1000")),
spawn_history_days: Some(14),
},
registry: RegistrySection {
bind_addr: Some("0.0.0.0:8080".parse().unwrap()),
},
web: WebSection {
bind_addr: Some("127.0.0.1:8080".parse().unwrap()),
daemon_url: Some("http://127.0.0.1:7070".into()),
@@ -751,8 +614,8 @@ presets = [
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
MaestroConfigFile {
shared: SharedSection {
token: Some("t".into()),
daemon: DaemonSection {
hostname: Some("laptop".into()),
..Default::default()
},
..Default::default()
@@ -765,27 +628,22 @@ presets = [
#[test]
fn partial_file_loads_with_section_defaults() {
// Only [shared] + [registry] present (typical registry host).
// Only [daemon] present (typical single-host install).
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
token = "abc"
[registry]
[daemon]
bind_addr = "127.0.0.1:9090"
"#,
)
.unwrap();
let cfg = MaestroConfigFile::load(&path).unwrap();
assert_eq!(cfg.shared.token.as_deref(), Some("abc"));
assert!(cfg.shared.registry_url.is_none());
assert_eq!(cfg.cli, CliSection::default());
assert_eq!(cfg.daemon, DaemonSection::default());
assert_eq!(cfg.web, WebSection::default());
assert_eq!(
cfg.registry.bind_addr,
cfg.daemon.bind_addr,
Some("127.0.0.1:9090".parse().unwrap())
);
}
@@ -797,18 +655,17 @@ bind_addr = "127.0.0.1:9090"
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = MaestroConfigFile {
shared: SharedSection {
token: Some("t".into()),
daemon: DaemonSection {
hostname: Some("laptop".into()),
..Default::default()
},
..Default::default()
};
cfg.save(&path).unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("[shared]"), "body: {body}");
assert!(body.contains("[daemon]"), "body: {body}");
assert!(!body.contains("[cli]"), "body: {body}");
assert!(!body.contains("[daemon]"), "body: {body}");
assert!(!body.contains("[registry]"), "body: {body}");
assert!(!body.contains("[web]"), "body: {body}");
}
#[test]
@@ -827,47 +684,4 @@ bind_addr = "127.0.0.1:9090"
let p = default_config_path().unwrap();
assert!(p.ends_with(format!("{DIR_NAME}/{FILE_NAME}")));
}
#[test]
fn derive_daemon_ws_url_swaps_http_to_ws() {
assert_eq!(
derive_daemon_ws_url("http://r.example").unwrap(),
"ws://r.example/daemon"
);
assert_eq!(
derive_daemon_ws_url("http://r.example:8080").unwrap(),
"ws://r.example:8080/daemon"
);
}
#[test]
fn derive_daemon_ws_url_swaps_https_to_wss() {
assert_eq!(
derive_daemon_ws_url("https://r.example").unwrap(),
"wss://r.example/daemon"
);
}
#[test]
fn derive_daemon_ws_url_normalises_trailing_slash() {
assert_eq!(
derive_daemon_ws_url("https://r.example/").unwrap(),
"wss://r.example/daemon"
);
assert_eq!(
derive_daemon_ws_url("https://r.example/path/").unwrap(),
"wss://r.example/path/daemon"
);
}
#[test]
fn derive_daemon_ws_url_rejects_non_http_schemes() {
let err = derive_daemon_ws_url("ws://r.example/daemon").unwrap_err();
match err {
ConfigError::InvalidRegistryUrl { input, .. } => {
assert_eq!(input, "ws://r.example/daemon");
}
other => panic!("expected InvalidRegistryUrl, got {other:?}"),
}
}
}
+3 -6
View File
@@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Per-host tmux wrapper + session orchestrator that talks to the registry"
description = "Per-host tmux wrapper + session orchestrator"
[lib]
path = "src/lib.rs"
@@ -16,7 +16,6 @@ chrono.workspace = true
clap.workspace = true
dirs.workspace = true
futures-util.workspace = true
jsonwebtoken.workspace = true
libc.workspace = true
maestro-config.workspace = true
maestro-protocol.workspace = true
@@ -24,16 +23,14 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
urlencoding = "2"
uuid.workspace = true
[dev-dependencies]
reqwest.workspace = true
tokio-tungstenite.workspace = true
urlencoding = "2"
tempfile.workspace = true
maestro-registry = { path = "../maestro-registry" }
# `test-util` enables `start_paused` for instant timeout tests (NAP-270).
tokio = { workspace = true, features = ["test-util"] }
+5 -95
View File
@@ -1,108 +1,18 @@
//! Per-session attach endpoint: `WS /sessions/:id/stream?token=<jwt>`.
//! PTY pump shared by the attach endpoints.
//!
//! - Binary frames carry raw PTY bytes both directions
//! - Text frames carry [`maestro_protocol::ClientControl`] (Resize/Ping/Pong)
//! - JWT is HS256, validated locally with no callback to the registry
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::any;
use axum::Router;
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::ClientControl;
use serde::Deserialize;
use tokio::sync::broadcast::error::RecvError;
use uuid::Uuid;
use crate::jwt::{JwtVerifier, PairAttachVerifier};
use crate::session::SessionManager;
#[derive(Debug, Clone)]
pub struct AttachState {
pub manager: SessionManager,
pub verifier: Arc<JwtVerifier>,
/// Pair-scoped JWT verifier (NAP-247). When the legacy verifier fails
/// (e.g. the token was minted as a pair-attach JWT for the broker path),
/// we fall back to this one before rejecting the client outright. Local
/// attaches always go through the legacy verifier.
pub pair_verifier: Arc<PairAttachVerifier>,
}
#[derive(Debug, Deserialize)]
struct AttachParams {
token: String,
}
pub fn router(
manager: SessionManager,
verifier: JwtVerifier,
pair_verifier: PairAttachVerifier,
) -> Router {
Router::new()
.route("/sessions/:id/stream", any(attach_ws))
.with_state(AttachState {
manager,
verifier: Arc::new(verifier),
pair_verifier: Arc::new(pair_verifier),
})
}
async fn attach_ws(
AxumPath(id): AxumPath<Uuid>,
Query(params): Query<AttachParams>,
State(state): State<AttachState>,
ws: WebSocketUpgrade,
) -> Result<Response, (StatusCode, String)> {
// Try the legacy `JwtClaims` shape first (browser/CLI direct attach). On
// any failure, try the pair-scoped shape used by the home-daemon broker
// (NAP-247). One — and only one — must accept.
let legacy_err = match state.verifier.verify(&params.token) {
Ok(claims) => {
if claims.session_id != id {
return Err((
StatusCode::FORBIDDEN,
format!(
"token is for session {} but path requested {}",
claims.session_id, id
),
));
}
None
}
Err(e) => Some(e),
};
if let Some(legacy_err) = legacy_err {
match state.pair_verifier.verify(&params.token, id) {
Ok(sub) => {
tracing::debug!(home_daemon = %sub, %id, "pair-scoped attach accepted");
}
Err(pair_err) => {
// Both rejected — return the legacy error since most clients
// still mint that shape today.
return Err((
StatusCode::UNAUTHORIZED,
format!("legacy: {legacy_err}; pair: {pair_err}"),
));
}
}
}
if !state.manager.has(id).await {
return Err((StatusCode::NOT_FOUND, format!("no session {id}")));
}
let manager = state.manager.clone();
Ok(ws.on_upgrade(move |socket| async move {
let _ = serve_local(socket, manager, id).await;
}))
}
/// Pump a WebSocket against a local session's PTY. Reused by both the legacy
/// `/sessions/:id/stream` endpoint (after JWT auth) and the new broker
/// `WS /api/attach/:id` endpoint (when the session is local-owned — NAP-243).
pub(crate) async fn serve_local(
/// Pump a WebSocket against a session's PTY, behind `WS /api/attach/:id`.
pub(crate) async fn serve(
socket: WebSocket,
manager: SessionManager,
id: Uuid,
@@ -112,7 +22,7 @@ pub(crate) async fn serve_local(
let (replay, mut rx) = match manager.subscribe_pty_with_replay(id).await {
Some(pair) => pair,
None => {
// session was killed between the auth check and now — let the
// session was killed between the existence check and now — let the
// client see a clean close instead of a dropped socket.
let _ = sink.send(Message::Close(None)).await;
return Ok(());
-822
View File
@@ -1,822 +0,0 @@
//! Home-daemon broker handle (NAP-247, extended in NAP-327).
//!
//! The HTTP handlers use this to issue correlated request/reply RPCs over the
//! daemon's single registry control link, keeping themselves thin:
//!
//! - `WS /api/attach/:id` → `resolve_attach`: resolve a peer-owned session into
//! an [`AttachCandidates`] (pair-scoped JWT + T2 perimeter info) (NAP-247).
//! - `POST /api/sessions` / `DELETE /api/sessions/:id` for a peer target →
//! `forward_create` / `forward_kill`: relay the op to the owning daemon and
//! await its result (NAP-327).
//!
//! Each call correlates its reply by `reply_to`, fails fast while the link is
//! down (NAP-263), and cleans up its pending entry on every exit path (NAP-259).
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use maestro_protocol::{
AttachCandidates, CreateSessionRequest, DaemonOutbound, DirListing, GitRoot, RecentDirs,
ReplyTo, RpcResult, Session,
};
use tokio::sync::{mpsc, oneshot};
use uuid::Uuid;
/// RAII cleanup for a pending-RPC entry. Removing the entry on drop guarantees
/// it is gone on *every* exit path of the awaiting call — success, error,
/// timeout, and crucially future cancellation (the axum handler is dropped when
/// the client hangs up) — so the map can't grow without bound (NAP-259).
///
/// The map is a `std::sync::Mutex` (never held across an await) precisely so the
/// sync `Drop` can lock it. Removal is idempotent: on the happy path the reply
/// handler has already taken the entry and this is a harmless no-op.
struct PendingGuard<'a, K: Eq + Hash, V> {
map: &'a Mutex<HashMap<K, V>>,
key: K,
}
impl<K: Eq + Hash, V> Drop for PendingGuard<'_, K, V> {
fn drop(&mut self) {
self.map.lock().unwrap().remove(&self.key);
}
}
/// How long to wait for a `ResolveAttachResult` after the request goes out.
/// Matches the registry's RPC timeout — a healthy registry replies in ms.
pub const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, thiserror::Error)]
pub enum BrokerError {
#[error("registry control link unavailable")]
NoControlLink,
#[error("registry reply channel closed before response")]
ReplyClosed,
#[error("registry did not reply within {0:?}")]
Timeout(Duration),
#[error("registry rejected resolve: {0}: {1}")]
Registry(String, String),
}
/// Shared between the registry-client task (producer of `ResolveAttachResult`)
/// and the broker HTTP handler (consumer). Clone freely — all fields are
/// behind `Arc`s.
#[derive(Debug, Clone)]
pub struct BrokerHandle {
out_tx: mpsc::Sender<DaemonOutbound>,
pending: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<AttachCandidates>>>>>,
/// Pending `ForwardCreate` replies, keyed by `reply_to` (NAP-327). Same
/// fail-fast + RAII-cleanup discipline as `pending`.
pending_create: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<Session>>>>>,
/// Pending `ForwardKill` replies, keyed by `reply_to` (NAP-327).
pending_kill: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<()>>>>>,
/// Pending `ForwardListDir` replies, keyed by `reply_to` (NAP-329).
pending_list_dir: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<DirListing>>>>>,
/// Pending `ForwardRecentDirs` replies, keyed by `reply_to` (#26).
pending_recent_dirs: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<RecentDirs>>>>>,
/// Pending `ForwardGitRoot` replies, keyed by `reply_to` (#26).
pending_git_root: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<GitRoot>>>>>,
/// Whether the registry control link is currently up. Flipped by the
/// registry-client task via [`BrokerHandle::set_connected`]. When down,
/// `resolve_attach` fails fast instead of queueing onto an undrained
/// channel that would otherwise fill and block every caller (NAP-263).
connected: Arc<AtomicBool>,
}
impl BrokerHandle {
/// `(handle, receiver)` — the registry-client task owns the receiver and
/// drains it into the control-link WS sink. The handle is shared with
/// HTTP handlers and is what they use to issue requests.
///
/// Starts **disconnected**: the registry-client task calls
/// [`set_connected(true)`](Self::set_connected) once the handshake lands.
pub fn new() -> (Self, mpsc::Receiver<DaemonOutbound>) {
let (out_tx, out_rx) = mpsc::channel(64);
let handle = Self {
out_tx,
pending: Arc::new(Mutex::new(HashMap::new())),
pending_create: Arc::new(Mutex::new(HashMap::new())),
pending_kill: Arc::new(Mutex::new(HashMap::new())),
pending_list_dir: Arc::new(Mutex::new(HashMap::new())),
pending_recent_dirs: Arc::new(Mutex::new(HashMap::new())),
pending_git_root: Arc::new(Mutex::new(HashMap::new())),
connected: Arc::new(AtomicBool::new(false)),
};
(handle, out_rx)
}
/// Mark the control link up/down. Called by the registry-client task on
/// every connect (`true`) and disconnect (`false`).
pub fn set_connected(&self, connected: bool) {
self.connected.store(connected, Ordering::Release);
}
/// Resolve a peer session: ask the registry for an [`AttachCandidates`].
pub async fn resolve_attach(&self, session_id: Uuid) -> Result<AttachCandidates, BrokerError> {
// Fail fast when the registry link is down — otherwise the bounded
// outbound channel (nothing draining it while disconnected) fills up
// and every subsequent caller blocks on `send().await` (NAP-263).
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(reply_to, tx);
// From here on, every exit path (including cancellation of this future)
// removes the pending entry when `_guard` drops (NAP-259).
let _guard = PendingGuard {
map: &*self.pending,
key: reply_to,
};
// Non-blocking send: a full channel (registry up but back-pressured)
// is treated as "link unavailable" rather than parking the caller.
if self
.out_tx
.try_send(DaemonOutbound::ResolveAttach {
reply_to,
session_id,
})
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { value })) => Ok(value),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Called by the registry-client task when a `ResolveAttachResult` frame
/// comes back from the registry. Resolves the waiting `resolve_attach`
/// future if one's still alive.
pub async fn complete_resolve(
&self,
reply_to: ReplyTo,
result: RpcResult<AttachCandidates>,
) -> bool {
let entry = self.pending.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ResolveAttachResult");
false
}
}
}
/// Broker a cross-daemon create: ask the registry to relay the request to
/// the owning daemon (`request.daemon_id`) and return the created session.
/// Same fail-fast-while-disconnected + RAII-cleanup discipline as
/// [`resolve_attach`](Self::resolve_attach) (NAP-263 / NAP-259, NAP-327).
pub async fn forward_create(
&self,
request: CreateSessionRequest,
) -> Result<Session, BrokerError> {
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending_create.lock().unwrap().insert(reply_to, tx);
let _guard = PendingGuard {
map: &*self.pending_create,
key: reply_to,
};
if self
.out_tx
.try_send(DaemonOutbound::ForwardCreate { reply_to, request })
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { value })) => Ok(value),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Broker a cross-daemon kill: ask the registry to relay the kill to the
/// daemon that owns `id` (NAP-327).
pub async fn forward_kill(&self, daemon_id: String, id: Uuid) -> Result<(), BrokerError> {
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending_kill.lock().unwrap().insert(reply_to, tx);
let _guard = PendingGuard {
map: &*self.pending_kill,
key: reply_to,
};
if self
.out_tx
.try_send(DaemonOutbound::ForwardKill {
reply_to,
daemon_id,
id,
})
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { .. })) => Ok(()),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Broker a cross-daemon directory listing: ask the registry to relay the
/// `ListDir` to the daemon named by `daemon_id` (which may be a peer) and
/// return its listing. Same fail-fast-while-disconnected + RAII-cleanup
/// discipline as the other broker calls (NAP-329).
pub async fn forward_list_dir(
&self,
daemon_id: String,
path: String,
) -> Result<DirListing, BrokerError> {
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending_list_dir.lock().unwrap().insert(reply_to, tx);
let _guard = PendingGuard {
map: &*self.pending_list_dir,
key: reply_to,
};
if self
.out_tx
.try_send(DaemonOutbound::ForwardListDir {
reply_to,
daemon_id,
path,
})
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { value })) => Ok(value),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Resolve a waiting [`forward_list_dir`](Self::forward_list_dir); called by
/// the registry-client task on a `ForwardListDirResult` frame.
pub async fn complete_forward_list_dir(
&self,
reply_to: ReplyTo,
result: RpcResult<DirListing>,
) -> bool {
let entry = self.pending_list_dir.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ForwardListDirResult");
false
}
}
}
/// Broker a cross-daemon recent-dirs query: ask the registry to relay a
/// `RecentDirs` request to the daemon named by `daemon_id` (which may be a
/// peer) and return its recents. Same discipline as the other broker calls
/// (#26).
pub async fn forward_recent_dirs(&self, daemon_id: String) -> Result<RecentDirs, BrokerError> {
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending_recent_dirs
.lock()
.unwrap()
.insert(reply_to, tx);
let _guard = PendingGuard {
map: &*self.pending_recent_dirs,
key: reply_to,
};
if self
.out_tx
.try_send(DaemonOutbound::ForwardRecentDirs {
reply_to,
daemon_id,
})
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { value })) => Ok(value),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Resolve a waiting [`forward_recent_dirs`](Self::forward_recent_dirs);
/// called by the registry-client task on a `ForwardRecentDirsResult` frame.
pub async fn complete_forward_recent_dirs(
&self,
reply_to: ReplyTo,
result: RpcResult<RecentDirs>,
) -> bool {
let entry = self.pending_recent_dirs.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ForwardRecentDirsResult");
false
}
}
}
/// Broker a cross-daemon gitroot probe: ask the registry to relay a
/// `GitRoot` request for `path` to the daemon named by `daemon_id` (which
/// may be a peer) and return its result (#26).
pub async fn forward_git_root(
&self,
daemon_id: String,
path: String,
) -> Result<GitRoot, BrokerError> {
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending_git_root.lock().unwrap().insert(reply_to, tx);
let _guard = PendingGuard {
map: &*self.pending_git_root,
key: reply_to,
};
if self
.out_tx
.try_send(DaemonOutbound::ForwardGitRoot {
reply_to,
daemon_id,
path,
})
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { value })) => Ok(value),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Resolve a waiting [`forward_git_root`](Self::forward_git_root); called by
/// the registry-client task on a `ForwardGitRootResult` frame.
pub async fn complete_forward_git_root(
&self,
reply_to: ReplyTo,
result: RpcResult<GitRoot>,
) -> bool {
let entry = self.pending_git_root.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ForwardGitRootResult");
false
}
}
}
/// Resolve a waiting [`forward_create`](Self::forward_create); called by the
/// registry-client task on a `ForwardCreateResult` frame.
pub async fn complete_forward_create(
&self,
reply_to: ReplyTo,
result: RpcResult<Session>,
) -> bool {
let entry = self.pending_create.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ForwardCreateResult");
false
}
}
}
/// Resolve a waiting [`forward_kill`](Self::forward_kill); called by the
/// registry-client task on a `ForwardKillResult` frame.
pub async fn complete_forward_kill(&self, reply_to: ReplyTo, result: RpcResult<()>) -> bool {
let entry = self.pending_kill.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ForwardKillResult");
false
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Cancelling (dropping) an in-flight `resolve_attach` must not leak its
/// pending-reply entry — otherwise a browser hanging up mid-attach grows
/// the map without bound (NAP-259).
#[tokio::test]
async fn cancelled_resolve_attach_removes_pending_entry() {
// Keep the receiver alive so `out_tx.send()` succeeds and the call
// parks on the reply oneshot rather than failing fast.
let (handle, _out_rx) = BrokerHandle::new();
handle.set_connected(true);
{
let fut = handle.resolve_attach(Uuid::new_v4());
tokio::pin!(fut);
tokio::select! {
biased;
_ = &mut fut => panic!("resolve must not complete without a reply"),
_ = tokio::time::sleep(Duration::from_millis(20)) => {}
}
assert_eq!(
handle.pending.lock().unwrap().len(),
1,
"entry should be in-flight before cancellation"
);
}
assert_eq!(
handle.pending.lock().unwrap().len(),
0,
"pending entry must be removed on cancellation (NAP-259)"
);
}
/// NAP-263: while the registry link is down, `resolve_attach` must return
/// promptly instead of queueing onto the undrained outbound channel (which
/// would fill and deadlock callers). The receiver is alive with free
/// capacity here, so a `NoControlLink` proves the disconnected gate — not a
/// closed/full channel — short-circuited the call.
#[tokio::test]
async fn resolve_attach_fails_fast_while_disconnected() {
let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected
let res = tokio::time::timeout(
Duration::from_secs(1),
handle.resolve_attach(Uuid::new_v4()),
)
.await
.expect("resolve_attach must return promptly, not block on a full channel");
assert!(matches!(res, Err(BrokerError::NoControlLink)), "{res:?}");
// It must not leave a pending entry behind either.
assert_eq!(handle.pending.lock().unwrap().len(), 0);
}
/// NAP-270: a registry-reported error in the `ResolveAttachResult` reply is
/// surfaced as `BrokerError::Registry` carrying the registry's error code,
/// so the broker handler can map it to the right HTTP status.
#[tokio::test]
async fn broker_resolve_attach_propagates_registry_err() {
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task = tokio::spawn(async move { h.resolve_attach(Uuid::new_v4()).await });
// Learn the reply_to from the outbound frame, then deliver an error.
let reply_to = match out_rx.recv().await.expect("ResolveAttach went out") {
DaemonOutbound::ResolveAttach { reply_to, .. } => reply_to,
other => panic!("expected ResolveAttach, got {other:?}"),
};
handle
.complete_resolve(
reply_to,
RpcResult::Err {
error: maestro_protocol::ErrorInfo {
code: "unknown_session".into(),
message: "no daemon owns it".into(),
},
},
)
.await;
match task.await.unwrap() {
Err(BrokerError::Registry(code, _)) => assert_eq!(code, "unknown_session"),
other => panic!("expected Registry error, got {other:?}"),
}
}
// ----- NAP-327: forward_create / forward_kill -----
fn sample_request() -> CreateSessionRequest {
CreateSessionRequest {
daemon_id: "konishi".into(),
cwd: std::path::PathBuf::from("/tmp"),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}
}
/// A `forward_create` issues a `ForwardCreate` frame and resolves with the
/// session once the registry's `ForwardCreateResult` is delivered.
#[tokio::test]
async fn forward_create_round_trips_session() {
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task = tokio::spawn(async move { h.forward_create(sample_request()).await });
let reply_to = match out_rx.recv().await.expect("ForwardCreate went out") {
DaemonOutbound::ForwardCreate { reply_to, request } => {
assert_eq!(request.daemon_id, "konishi");
reply_to
}
other => panic!("expected ForwardCreate, got {other:?}"),
};
let session = Session {
id: Uuid::new_v4(),
daemon_id: "konishi".into(),
cwd: std::path::PathBuf::from("/tmp"),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
status: maestro_protocol::SessionStatus::Running,
created_at: chrono::Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
};
handle
.complete_forward_create(
reply_to,
RpcResult::Ok {
value: session.clone(),
},
)
.await;
let got = task.await.unwrap().expect("create ok");
assert_eq!(got.id, session.id);
}
/// While disconnected, `forward_create` / `forward_kill` fail fast with
/// `NoControlLink` and leave no pending entry (NAP-263 / NAP-259).
#[tokio::test]
async fn forward_ops_fail_fast_while_disconnected() {
let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected
assert!(matches!(
handle.forward_create(sample_request()).await,
Err(BrokerError::NoControlLink)
));
assert!(matches!(
handle.forward_kill("konishi".into(), Uuid::new_v4()).await,
Err(BrokerError::NoControlLink)
));
assert_eq!(handle.pending_create.lock().unwrap().len(), 0);
assert_eq!(handle.pending_kill.lock().unwrap().len(), 0);
}
/// A registry-reported error in the reply surfaces as `BrokerError::Registry`
/// carrying the code, so the HTTP handler can map it to a status.
#[tokio::test]
async fn forward_kill_propagates_registry_err() {
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task =
tokio::spawn(async move { h.forward_kill("konishi".into(), Uuid::new_v4()).await });
let reply_to = match out_rx.recv().await.expect("ForwardKill went out") {
DaemonOutbound::ForwardKill { reply_to, .. } => reply_to,
other => panic!("expected ForwardKill, got {other:?}"),
};
handle
.complete_forward_kill(
reply_to,
RpcResult::Err {
error: maestro_protocol::ErrorInfo {
code: "daemon_not_connected".into(),
message: "owner offline".into(),
},
},
)
.await;
match task.await.unwrap() {
Err(BrokerError::Registry(code, _)) => assert_eq!(code, "daemon_not_connected"),
other => panic!("expected Registry error, got {other:?}"),
}
}
// ----- NAP-329: forward_list_dir -----
/// A `forward_list_dir` issues a `ForwardListDir` frame and resolves with
/// the listing once the registry's `ForwardListDirResult` is delivered.
#[tokio::test]
async fn forward_list_dir_round_trips_listing() {
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task =
tokio::spawn(async move { h.forward_list_dir("konishi".into(), "/work".into()).await });
let reply_to = match out_rx.recv().await.expect("ForwardListDir went out") {
DaemonOutbound::ForwardListDir {
reply_to,
daemon_id,
path,
} => {
assert_eq!(daemon_id, "konishi");
assert_eq!(path, "/work");
reply_to
}
other => panic!("expected ForwardListDir, got {other:?}"),
};
handle
.complete_forward_list_dir(
reply_to,
RpcResult::Ok {
value: DirListing {
path: std::path::PathBuf::from("/work"),
entries: vec!["repo".into()],
truncated: false,
},
},
)
.await;
let got = task.await.unwrap().expect("list ok");
assert_eq!(got.entries, vec!["repo".to_string()]);
}
/// While disconnected, `forward_list_dir` fails fast and leaves no pending
/// entry (NAP-263 / NAP-259).
#[tokio::test]
async fn forward_list_dir_fails_fast_while_disconnected() {
let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected
assert!(matches!(
handle
.forward_list_dir("konishi".into(), "/work".into())
.await,
Err(BrokerError::NoControlLink)
));
assert_eq!(handle.pending_list_dir.lock().unwrap().len(), 0);
}
// ----- #26: forward_recent_dirs / forward_git_root -----
/// `forward_recent_dirs` issues a `ForwardRecentDirs` frame and resolves
/// with the recents once the registry's `ForwardRecentDirsResult` lands.
#[tokio::test]
async fn forward_recent_dirs_round_trips() {
use maestro_protocol::{RecentDir, RecentDirs};
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task = tokio::spawn(async move { h.forward_recent_dirs("konishi".into()).await });
let reply_to = match out_rx.recv().await.expect("ForwardRecentDirs went out") {
DaemonOutbound::ForwardRecentDirs {
reply_to,
daemon_id,
} => {
assert_eq!(daemon_id, "konishi");
reply_to
}
other => panic!("expected ForwardRecentDirs, got {other:?}"),
};
handle
.complete_forward_recent_dirs(
reply_to,
RpcResult::Ok {
value: RecentDirs {
dirs: vec![RecentDir {
cwd: std::path::PathBuf::from("/work"),
last_command: "claude".into(),
last_used: chrono::Utc::now(),
}],
truncated: false,
},
},
)
.await;
let got = task.await.unwrap().expect("recents ok");
assert_eq!(got.dirs.len(), 1);
assert_eq!(got.dirs[0].cwd, std::path::PathBuf::from("/work"));
}
/// `forward_git_root` issues a `ForwardGitRoot` frame carrying the path and
/// resolves with the probe result.
#[tokio::test]
async fn forward_git_root_round_trips() {
use maestro_protocol::GitRoot;
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task = tokio::spawn(async move {
h.forward_git_root("konishi".into(), "/work/sub".into())
.await
});
let reply_to = match out_rx.recv().await.expect("ForwardGitRoot went out") {
DaemonOutbound::ForwardGitRoot {
reply_to,
daemon_id,
path,
} => {
assert_eq!(daemon_id, "konishi");
assert_eq!(path, "/work/sub");
reply_to
}
other => panic!("expected ForwardGitRoot, got {other:?}"),
};
handle
.complete_forward_git_root(
reply_to,
RpcResult::Ok {
value: GitRoot {
root: Some(std::path::PathBuf::from("/work")),
},
},
)
.await;
let got = task.await.unwrap().expect("gitroot ok");
assert_eq!(got.root, Some(std::path::PathBuf::from("/work")));
}
/// While disconnected, both new broker calls fail fast and leave no pending
/// entry (NAP-263 / NAP-259).
#[tokio::test]
async fn forward_recents_and_gitroot_fail_fast_while_disconnected() {
let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected
assert!(matches!(
handle.forward_recent_dirs("konishi".into()).await,
Err(BrokerError::NoControlLink)
));
assert!(matches!(
handle.forward_git_root("konishi".into(), "/x".into()).await,
Err(BrokerError::NoControlLink)
));
assert_eq!(handle.pending_recent_dirs.lock().unwrap().len(), 0);
assert_eq!(handle.pending_git_root.lock().unwrap().len(), 0);
}
/// NAP-270: if the registry never replies, `resolve_attach` resolves to
/// `BrokerError::Timeout` after `RESOLVE_TIMEOUT` (driven here with a paused
/// clock so the test is instant).
#[tokio::test(start_paused = true)]
async fn broker_resolve_attach_times_out_when_no_reply() {
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task = tokio::spawn(async move { h.resolve_attach(Uuid::new_v4()).await });
// The request is out and the call is parked on the reply oneshot.
let _ = out_rx.recv().await.expect("ResolveAttach went out");
// Never reply; jump past the RPC timeout.
tokio::time::advance(RESOLVE_TIMEOUT + Duration::from_secs(1)).await;
match task.await.unwrap() {
Err(BrokerError::Timeout(_)) => {}
other => panic!("expected Timeout, got {other:?}"),
}
}
}
-201
View File
@@ -1,201 +0,0 @@
//! Daemon-local mirror of the registry catalog (NAP-246).
//!
//! The registry fans out a stream of `RegistryEvent`s to each connected
//! daemon, filtered so each one only sees *peer* state. This module owns the
//! resulting in-memory copy and exposes read snapshots to the daemon's HTTP
//! handlers (`GET /api/sessions`, `GET /api/daemons`).
//!
//! The mirror does *not* include the daemon's own sessions — those come from
//! `SessionManager`. Combining the two for HTTP responses is the caller's
//! job (see `local_api.rs`).
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use maestro_protocol::{DaemonInfo, RegistryEvent, Session};
use tokio::sync::Mutex;
use uuid::Uuid;
#[derive(Debug, Default)]
struct State {
daemons: HashMap<String, DaemonInfo>,
/// `(daemon_id, session_id) -> Session` — mirrors the registry's catalog
/// keying so removals (which arrive as `(daemon_id, id)`) are O(1).
sessions: BTreeMap<(String, Uuid), Session>,
}
#[derive(Debug, Clone, Default)]
pub struct CatalogMirror {
inner: Arc<Mutex<State>>,
/// Optional fan-out (NAP-248). When attached, every applied event is
/// re-published here so `WS /api/events` subscribers see peer state
/// changes the same way the registry's clients do.
bus: Option<crate::local_events::LocalEventBus>,
}
impl CatalogMirror {
pub fn new() -> Self {
Self::default()
}
/// Attach a [`LocalEventBus`](crate::local_events::LocalEventBus) so peer
/// catalog deltas reach the daemon's `/api/events` subscribers.
pub fn with_bus(mut self, bus: crate::local_events::LocalEventBus) -> Self {
self.bus = Some(bus);
self
}
/// Apply one delta. Snapshot events replace the mirror wholesale;
/// `Lagged` resets it on the assumption that the next full snapshot will
/// arrive later (today the registry doesn't re-send one — see follow-up
/// notes in `handler.rs`).
pub async fn apply(&self, event: RegistryEvent) {
{
let mut s = self.inner.lock().await;
match &event {
RegistryEvent::Snapshot { daemons, sessions } => {
s.daemons.clear();
s.sessions.clear();
for d in daemons {
s.daemons.insert(d.daemon_id.clone(), d.clone());
}
for sess in sessions {
s.sessions
.insert((sess.daemon_id.clone(), sess.id), sess.clone());
}
}
RegistryEvent::DaemonAdded { daemon } => {
s.daemons.insert(daemon.daemon_id.clone(), daemon.clone());
}
RegistryEvent::DaemonRemoved { daemon_id } => {
s.daemons.remove(daemon_id);
s.sessions.retain(|(d, _), _| d != daemon_id);
}
RegistryEvent::SessionAdded { session }
| RegistryEvent::SessionUpdated { session } => {
s.sessions
.insert((session.daemon_id.clone(), session.id), session.clone());
}
RegistryEvent::SessionRemoved { daemon_id, id } => {
s.sessions.remove(&(daemon_id.clone(), *id));
}
RegistryEvent::Lagged { skipped } => {
tracing::warn!(
skipped = *skipped,
"catalog mirror lagged — local view may be stale"
);
}
}
}
// Re-publish AFTER releasing the lock so subscribers reading `daemons()`
// / `sessions()` in response observe the new state.
if let Some(bus) = &self.bus {
bus.publish(event);
}
}
/// Snapshot the peer daemons currently mirrored.
pub async fn daemons(&self) -> Vec<DaemonInfo> {
self.inner.lock().await.daemons.values().cloned().collect()
}
/// Snapshot every peer session currently mirrored.
pub async fn sessions(&self) -> Vec<Session> {
self.inner.lock().await.sessions.values().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use maestro_protocol::SessionStatus;
use std::collections::HashMap as StdHashMap;
fn peer_session(daemon: &str, id_lo: u128) -> Session {
Session {
id: Uuid::from_u128(id_lo),
daemon_id: daemon.into(),
cwd: "/tmp".into(),
command: "bash".into(),
env: StdHashMap::new(),
metadata: StdHashMap::new(),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
fn peer_daemon(daemon_id: &str) -> DaemonInfo {
DaemonInfo {
daemon_id: daemon_id.into(),
hostname: format!("{daemon_id}-host"),
version: "0.1.0".into(),
daemon_url: format!("ws://{daemon_id}:7070"),
home_dir: String::new(),
}
}
#[tokio::test]
async fn snapshot_replaces_state() {
let m = CatalogMirror::new();
m.apply(RegistryEvent::DaemonAdded {
daemon: peer_daemon("old"),
})
.await;
m.apply(RegistryEvent::Snapshot {
daemons: vec![peer_daemon("new")],
sessions: vec![peer_session("new", 1)],
})
.await;
let ds = m.daemons().await;
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].daemon_id, "new");
assert_eq!(m.sessions().await.len(), 1);
}
#[tokio::test]
async fn daemon_removed_cascades_to_sessions() {
let m = CatalogMirror::new();
m.apply(RegistryEvent::DaemonAdded {
daemon: peer_daemon("konishi"),
})
.await;
m.apply(RegistryEvent::SessionAdded {
session: peer_session("konishi", 1),
})
.await;
m.apply(RegistryEvent::SessionAdded {
session: peer_session("konishi", 2),
})
.await;
assert_eq!(m.sessions().await.len(), 2);
m.apply(RegistryEvent::DaemonRemoved {
daemon_id: "konishi".into(),
})
.await;
assert!(m.daemons().await.is_empty());
assert!(m.sessions().await.is_empty());
}
#[tokio::test]
async fn session_update_replaces_in_place() {
let m = CatalogMirror::new();
m.apply(RegistryEvent::SessionAdded {
session: peer_session("konishi", 1),
})
.await;
let mut updated = peer_session("konishi", 1);
updated.status = SessionStatus::Dead;
m.apply(RegistryEvent::SessionUpdated { session: updated })
.await;
let after = m.sessions().await;
assert_eq!(after.len(), 1);
assert_eq!(after[0].status, SessionStatus::Dead);
}
}
+9 -104
View File
@@ -1,8 +1,5 @@
//! Clap-derive subcommand for the daemon, plus a runner that wires together
//! everything the binary used to do in `main.rs`.
//!
//! Lifted out of the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
//! binary can compose this subcommand alongside the CLI and registry.
//! Clap-derive subcommand for the daemon, plus a runner that wires everything
//! together.
use std::error::Error;
use std::path::PathBuf;
@@ -15,52 +12,14 @@ use crate::sidecar::SidecarStore;
use crate::tmux::Tmux;
use crate::worktree::WorktreeManager;
use crate::{
attach, default_config_path, default_state_dir, load_config, local_api, run_configure,
spawn_session_publisher, ConfigureInputs, EnvOverrides, JwtVerifier, LocalEventBus,
PairAttachVerifier, RegistryClient, SessionManager,
default_config_path, default_state_dir, load_config, local_api, spawn_session_publisher,
EnvOverrides, LocalEventBus, SessionManager,
};
#[derive(Debug, Subcommand)]
pub enum DaemonCmd {
/// Start the daemon. Default when no subcommand is given.
Run,
/// Write `~/.config/maestro/config.toml` interactively or from flags.
Configure(ConfigureArgs),
}
#[derive(Debug, clap::Args)]
pub struct ConfigureArgs {
/// Registry base URL (e.g. `https://maestro.example.com`). The daemon
/// derives its WS endpoint by scheme-swapping and appending `/daemon`.
#[arg(long)]
pub registry_url: Option<String>,
/// Reachable URL clients dial for this daemon's attach WS.
#[arg(long)]
pub daemon_url: Option<String>,
/// Shared token (MAESTRO_TOKEN). Tip: pipe from `pass` / `op` / `secret-tool`.
#[arg(long)]
pub token: Option<String>,
/// Stable daemon ID. Defaults to the system hostname.
#[arg(long)]
pub daemon_id: Option<String>,
/// Local bind address for the per-session WS endpoint.
#[arg(long)]
pub bind_addr: Option<String>,
/// Override the daemon's state directory.
#[arg(long)]
pub state_dir: Option<PathBuf>,
/// Override the worktree root.
#[arg(long)]
pub worktree_root: Option<PathBuf>,
/// Override the recorded hostname (defaults to `gethostname(2)`).
#[arg(long)]
pub hostname: Option<String>,
/// Overwrite an existing config file.
#[arg(long)]
pub force: bool,
/// Disable interactive prompts (errors if any required field is missing).
#[arg(long)]
pub non_interactive: bool,
}
/// Dispatch a parsed daemon subcommand. `config_override` is the unified
@@ -71,7 +30,6 @@ pub async fn dispatch(
) -> Result<(), Box<dyn Error>> {
match cmd.unwrap_or(DaemonCmd::Run) {
DaemonCmd::Run => run(config_override).await,
DaemonCmd::Configure(args) => configure(config_override, args),
}
}
@@ -82,8 +40,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
};
let config = load_config(&config_path, EnvOverrides::from_env())?;
tracing::info!(
daemon_id = %config.daemon_id,
registry = %config.registry_url,
hostname = %config.hostname,
bind = %config.bind_addr,
"loaded config"
);
@@ -100,7 +57,6 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
None => Tmux::new(),
};
let manager = SessionManager::new_with_replay(
config.daemon_id.clone(),
tmux,
sidecar,
worktree_mgr,
@@ -116,51 +72,20 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
tracing::info!(recovered, "adopted existing tmux sessions");
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
let verifier = JwtVerifier::new(&config.token, &config.daemon_id);
let pair_verifier = PairAttachVerifier::new(&config.token, &config.daemon_id);
let events = LocalEventBus::new();
spawn_session_publisher(manager.clone(), events.clone());
let client = RegistryClient::new_with_bus(
config.registry_url.clone(),
config.daemon_id.clone(),
let router = local_api::router(local_api::state_for(
manager,
config.hostname.clone(),
config.daemon_url.clone(),
config.home_dir.clone(),
config.token.clone(),
manager.clone(),
Some(events.clone()),
)
.with_perimeter(config.perimeter());
let catalog = client.catalog();
let local_state = local_api::state_for(
manager.clone(),
config.hostname.clone(),
config.daemon_url.clone(),
config.home_dir.clone(),
config.spawn_presets.clone(),
catalog.clone(),
events,
client.broker(),
);
let broker_state = local_api::BrokerState {
manager: manager.clone(),
catalog,
broker: client.broker(),
self_daemon_id: std::sync::Arc::new(config.daemon_id.clone()),
self_perimeter_id: config.perimeter_id.clone().map(std::sync::Arc::new),
};
let router = attach::router(manager.clone(), verifier, pair_verifier)
.merge(local_api::router(local_state))
.merge(local_api::broker_router(broker_state));
));
let listener = TcpListener::bind(config.bind_addr).await?;
let bound = listener.local_addr()?;
tracing::info!(addr = %bound, "attach WS listening");
tokio::spawn(async move { client.run().await });
tracing::info!(addr = %bound, "API listening");
let shutdown = async {
let _ = tokio::signal::ctrl_c().await;
@@ -172,23 +97,3 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
.await?;
Ok(())
}
fn configure(config_override: Option<PathBuf>, args: ConfigureArgs) -> Result<(), Box<dyn Error>> {
let path: PathBuf = match config_override {
Some(p) => p,
None => default_config_path()?,
};
let inputs = ConfigureInputs {
registry_url: args.registry_url,
daemon_url: args.daemon_url,
token: args.token,
daemon_id: args.daemon_id,
bind_addr: args.bind_addr,
state_dir: args.state_dir,
worktree_root: args.worktree_root,
hostname: args.hostname,
};
let written = run_configure(&path, inputs, !args.non_interactive, args.force)?;
eprintln!("wrote {}", written.display());
Ok(())
}
+22 -309
View File
@@ -1,26 +1,21 @@
//! Daemon view over the unified Maestro config.
//!
//! Reads `[shared]` (registry_url, token) and `[daemon]` (daemon_id,
//! hostname, daemon_url, bind_addr, state_dir, worktree_root) from
//! `~/.config/maestro/config.toml`.
//! Reads `[daemon]` (hostname, bind_addr, state_dir, worktree_root, …) and
//! `[spawn]` from `~/.config/maestro/config.toml`.
//!
//! Precedence per field: **env > file > default**. The daemon refuses
//! to start if `registry_url`, `daemon_url`, or `token` are absent
//! from both env and file.
//! Precedence per field: **env > file > default**. Every field has a default,
//! so a missing config file is fine — the daemon starts with defaults.
//!
//! Default config path: `~/.config/maestro/config.toml` (override via
//! `$MAESTRO_CONFIG`). If the file is missing entirely the daemon
//! errors with a pointer at `maestrod configure` — legacy
//! `~/.config/maestrod/config.toml` is no longer read.
//! `$MAESTRO_CONFIG`).
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use maestro_config::{derive_daemon_ws_url, MaestroConfigFile};
use maestro_protocol::PerimeterCandidate;
use maestro_config::MaestroConfigFile;
pub use maestro_config::{
default_config_path, resolve_config_path, DaemonSection, SharedSection, CONFIG_PATH_ENV,
default_config_path, resolve_config_path, DaemonSection, CONFIG_PATH_ENV,
};
/// Default bind address when neither env nor file specify one.
@@ -30,20 +25,8 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
/// Env var that overrides `[shared].registry_url`.
pub const ENV_REGISTRY_URL: &str = "MAESTRO_REGISTRY_URL";
/// Env var that overrides `[shared].token`.
pub const ENV_TOKEN: &str = "MAESTRO_TOKEN";
/// Env var that overrides `[daemon].daemon_id`.
pub const ENV_DAEMON_ID: &str = "MAESTRO_DAEMON_ID";
/// Env var that overrides `[daemon].daemon_url`.
pub const ENV_DAEMON_URL: &str = "MAESTRO_DAEMON_URL";
/// Env var that overrides `[daemon].bind_addr`.
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
/// Env var that overrides `[daemon].perimeter_id`.
pub const ENV_DAEMON_PERIMETER_ID: &str = "MAESTRO_DAEMON_PERIMETER_ID";
/// Env var that overrides `[daemon].private_addr`.
pub const ENV_DAEMON_PRIVATE_ADDR: &str = "MAESTRO_DAEMON_PRIVATE_ADDR";
/// Env var that overrides `[daemon].tmux_tmpdir`.
pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR";
@@ -57,12 +40,6 @@ pub enum ConfigError {
Inner(#[from] maestro_config::ConfigError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("missing required config field `{field}` — set it in {path:?} or via env var `{env}`")]
Missing {
field: &'static str,
env: &'static str,
path: PathBuf,
},
#[error("invalid [spawn].presets: {0}")]
SpawnPresets(#[from] maestro_config::SpawnConfigError),
}
@@ -70,26 +47,15 @@ pub enum ConfigError {
/// Resolved, validated daemon configuration ready to drive the binary.
#[derive(Debug, Clone)]
pub struct DaemonConfig {
pub daemon_id: String,
/// Hostname advertised to clients, for display only.
pub hostname: String,
/// Home directory of the OS user the daemon runs as, advertised to clients
/// so they can prefill a spawn form's working directory. Empty when `$HOME`
/// can't be resolved (NAP-328).
pub home_dir: String,
/// Registry WS URL the daemon dials. Derived from `[shared].registry_url`
/// via [`maestro_config::derive_daemon_ws_url`].
pub registry_url: String,
pub bind_addr: SocketAddr,
pub daemon_url: String,
pub token: String,
pub state_dir: PathBuf,
pub worktree_root: PathBuf,
/// Operator-set perimeter identifier. Advertised to the registry in
/// heartbeats so peers can dial this daemon over T2 when they share it.
pub perimeter_id: Option<String>,
/// Daemon's reachable address on the declared perimeter. Only meaningful
/// when `perimeter_id` is set.
pub private_addr: Option<String>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
/// receives this much of the prior PTY output before the live stream.
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
@@ -104,8 +70,8 @@ pub struct DaemonConfig {
/// [`DEFAULT_SPAWN_HISTORY_DAYS`].
pub spawn_history_days: u32,
/// Resolved spawn-form command presets (#22/#26): `[spawn].presets` (or the
/// built-ins) with the implicit `custom` tile appended. Advertised to the
/// web in `GET /api/web/info`.
/// built-ins) with the implicit `custom` tile appended. Advertised to
/// clients in `GET /api/info`.
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
}
@@ -122,43 +88,13 @@ impl DaemonConfig {
pub fn spawn_history_path(&self) -> PathBuf {
self.state_dir.join("spawn-history.jsonl")
}
/// Combine the two perimeter config keys into the typed advertisement the
/// daemon broadcasts. A perimeter is only advertised when BOTH the id and
/// the private address are set — this is the single place the "both or
/// neither" invariant lives (NAP-272). A half-configured pair is dropped
/// with a warning rather than silently advertising a useless value.
pub fn perimeter(&self) -> Option<PerimeterCandidate> {
match (self.perimeter_id.clone(), self.private_addr.clone()) {
(Some(perimeter_id), Some(private_addr)) => Some(PerimeterCandidate {
perimeter_id,
private_addr,
}),
(None, None) => None,
(id, addr) => {
tracing::warn!(
perimeter_id = ?id,
private_addr = ?addr,
"ignoring half-configured perimeter — set both [daemon].perimeter_id and \
[daemon].private_addr, or neither"
);
None
}
}
}
}
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
/// `Option`s; production reads from real env vars in `main`.
#[derive(Debug, Clone, Default)]
pub struct EnvOverrides {
pub daemon_id: Option<String>,
pub registry_url: Option<String>,
pub bind_addr: Option<String>,
pub daemon_url: Option<String>,
pub token: Option<String>,
pub perimeter_id: Option<String>,
pub private_addr: Option<String>,
pub tmux_tmpdir: Option<String>,
}
@@ -166,40 +102,17 @@ impl EnvOverrides {
/// Read every supported env var from the process environment.
pub fn from_env() -> Self {
Self {
daemon_id: std::env::var(ENV_DAEMON_ID).ok(),
registry_url: std::env::var(ENV_REGISTRY_URL).ok(),
bind_addr: std::env::var(ENV_DAEMON_BIND_ADDR).ok(),
daemon_url: std::env::var(ENV_DAEMON_URL).ok(),
token: std::env::var(ENV_TOKEN).ok(),
perimeter_id: std::env::var(ENV_DAEMON_PERIMETER_ID).ok(),
private_addr: std::env::var(ENV_DAEMON_PRIVATE_ADDR).ok(),
tmux_tmpdir: std::env::var(ENV_DAEMON_TMUX_TMPDIR).ok(),
}
}
}
/// Resolve the runtime configuration. Applies env overrides on top of the
/// unified file, fills defaults, validates the three required fields, and
/// derives the daemon's WS endpoint from the shared registry URL.
///
/// Returns [`maestro_config::ConfigError::MissingFile`] (wrapped) when the
/// file is absent and not all required fields come from env vars — this is
/// the hard-cutover error that points the operator at `maestrod configure`.
/// Resolve the runtime configuration: env overrides on top of the unified
/// file, then defaults. A missing file is not an error.
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, ConfigError> {
let file = if path.exists() {
MaestroConfigFile::load(path)?
} else if env_has_all_required(&env) {
// Container / systemd path: every required field comes from env,
// so the on-disk file is genuinely optional.
MaestroConfigFile::default()
} else {
return Err(maestro_config::ConfigError::MissingFile {
path: path.to_path_buf(),
}
.into());
};
let file = MaestroConfigFile::load(path)?;
let shared = file.shared;
let daemon = file.daemon;
// Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id,
// empty command) refuses startup with a clear error, like other config
@@ -210,32 +123,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let daemon_id = env
.daemon_id
.or(daemon.daemon_id)
.unwrap_or_else(|| hostname.clone());
let registry_url = derive_daemon_ws_url(&env.registry_url.or(shared.registry_url).ok_or(
ConfigError::Missing {
field: "shared.registry_url",
env: ENV_REGISTRY_URL,
path: path.to_path_buf(),
},
)?)?;
let daemon_url = env
.daemon_url
.or(daemon.daemon_url)
.ok_or(ConfigError::Missing {
field: "daemon.daemon_url",
env: ENV_DAEMON_URL,
path: path.to_path_buf(),
})?;
let token = env.token.or(shared.token).ok_or(ConfigError::Missing {
field: "shared.token",
env: ENV_TOKEN,
path: path.to_path_buf(),
})?;
let bind_addr = match env.bind_addr {
Some(raw) => maestro_config::parse_bind_addr(&raw, ENV_DAEMON_BIND_ADDR)?,
@@ -250,8 +137,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
.worktree_root
.unwrap_or_else(|| state_dir.join("worktrees"));
let perimeter_id = env.perimeter_id.or(daemon.perimeter_id);
let private_addr = env.private_addr.or(daemon.private_addr);
let pty_replay_bytes = daemon
.pty_replay_bytes
.unwrap_or(crate::pty::DEFAULT_REPLAY_BYTES);
@@ -265,17 +150,11 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
Ok(DaemonConfig {
daemon_id,
hostname,
home_dir,
registry_url,
bind_addr,
daemon_url,
token,
state_dir,
worktree_root,
perimeter_id,
private_addr,
pty_replay_bytes,
tmux_tmpdir,
spawn_history_days,
@@ -283,10 +162,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
})
}
fn env_has_all_required(env: &EnvOverrides) -> bool {
env.registry_url.is_some() && env.daemon_url.is_some() && env.token.is_some()
}
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
Ok(maestro_config::default_state_dir("maestro")?)
}
@@ -307,60 +182,27 @@ pub fn system_hostname() -> String {
mod tests {
use super::*;
fn minimal_file() -> &'static str {
r#"
[shared]
registry_url = "https://r.example"
token = "abc"
[daemon]
daemon_url = "ws://localhost:7070"
"#
}
#[test]
fn minimal_file_defaults_the_rest() {
fn empty_file_defaults_everything() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.registry_url, "wss://r.example/daemon");
assert_eq!(cfg.daemon_url, "ws://localhost:7070");
assert_eq!(cfg.token, "abc");
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(cfg.state_dir.ends_with("maestro"));
assert!(cfg.worktree_root.ends_with("worktrees"));
assert_eq!(cfg.daemon_id, cfg.hostname);
assert!(!cfg.hostname.is_empty());
}
#[test]
fn missing_file_with_full_env_works() {
// systemd / container scenario: no config file, everything in env.
fn missing_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
let env = EnvOverrides {
registry_url: Some("http://r.example".into()),
daemon_url: Some("ws://x:7070".into()),
token: Some("t".into()),
..EnvOverrides::default()
};
let cfg = load_config(&path, env).unwrap();
assert_eq!(cfg.registry_url, "ws://r.example/daemon");
assert_eq!(cfg.token, "t");
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(!path.exists(), "load_config must not create the file");
}
#[test]
fn missing_file_without_full_env_errors_with_hard_cutover_message() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
let err = load_config(&path, EnvOverrides::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("maestrod configure"), "msg: {msg}");
assert!(msg.contains("Legacy"), "msg: {msg}");
}
#[test]
fn env_overrides_file() {
let dir = tempfile::tempdir().unwrap();
@@ -368,68 +210,24 @@ daemon_url = "ws://localhost:7070"
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://from-file"
token = "from-file"
[daemon]
daemon_id = "from-file"
daemon_url = "ws://from-file:1"
bind_addr = "127.0.0.1:1"
"#,
)
.unwrap();
let env = EnvOverrides {
daemon_id: Some("from-env".into()),
registry_url: Some("https://from-env".into()),
bind_addr: Some("0.0.0.0:9999".into()),
daemon_url: Some("ws://from-env:1".into()),
token: Some("from-env".into()),
..EnvOverrides::default()
};
let cfg = load_config(&path, env).unwrap();
assert_eq!(cfg.daemon_id, "from-env");
assert_eq!(cfg.registry_url, "wss://from-env/daemon");
assert_eq!(cfg.daemon_url, "ws://from-env:1");
assert_eq!(cfg.token, "from-env");
assert_eq!(cfg.bind_addr, "0.0.0.0:9999".parse().unwrap());
}
#[test]
fn missing_required_field_reports_path_and_env_name() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
[daemon]
daemon_url = "ws://x"
"#,
)
.unwrap();
let err = load_config(&path, EnvOverrides::default()).unwrap_err();
match err {
ConfigError::Missing {
field,
env,
path: p,
} => {
assert_eq!(field, "shared.token");
assert_eq!(env, ENV_TOKEN);
assert_eq!(p, path);
}
other => panic!("expected Missing(shared.token), got {other:?}"),
}
}
#[test]
fn invalid_env_bind_addr_returns_diagnostic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
std::fs::write(&path, "").unwrap();
let env = EnvOverrides {
bind_addr: Some("not-an-addr".into()),
..EnvOverrides::default()
@@ -447,97 +245,27 @@ daemon_url = "ws://x"
}
#[test]
fn daemon_id_defaults_to_hostname() {
fn hostname_from_file_wins_over_gethostname() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
token = "t"
[daemon]
hostname = "vm-42"
daemon_url = "ws://x"
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.daemon_id, "vm-42");
assert_eq!(cfg.hostname, "vm-42");
}
#[test]
fn registry_url_must_be_http_or_https() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
registry_url = "ws://r/daemon"
token = "t"
[daemon]
daemon_url = "ws://x"
"#,
)
.unwrap();
let err = load_config(&path, EnvOverrides::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("registry_url"), "msg: {msg}");
}
#[test]
fn perimeter_fields_round_trip_through_file_and_env() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
token = "t"
[daemon]
daemon_url = "ws://x"
perimeter_id = "wg-naps62"
private_addr = "10.0.0.7:7070"
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.perimeter_id.as_deref(), Some("wg-naps62"));
assert_eq!(cfg.private_addr.as_deref(), Some("10.0.0.7:7070"));
// Env beats file.
let env = EnvOverrides {
perimeter_id: Some("lan-home".into()),
private_addr: Some("192.168.1.10:7070".into()),
..EnvOverrides::default()
};
let cfg = load_config(&path, env).unwrap();
assert_eq!(cfg.perimeter_id.as_deref(), Some("lan-home"));
assert_eq!(cfg.private_addr.as_deref(), Some("192.168.1.10:7070"));
}
#[test]
fn perimeter_fields_default_to_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert!(cfg.perimeter_id.is_none());
assert!(cfg.private_addr.is_none());
}
#[test]
fn spawn_history_days_defaults_and_overrides() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.spawn_history_days, DEFAULT_SPAWN_HISTORY_DAYS);
assert!(cfg.spawn_history_path().ends_with("spawn-history.jsonl"));
@@ -545,12 +273,7 @@ private_addr = "10.0.0.7:7070"
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
token = "t"
[daemon]
daemon_url = "ws://x"
spawn_history_days = 0
"#,
)
@@ -576,12 +299,7 @@ spawn_history_days = 0
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
token = "t"
[daemon]
daemon_url = "ws://x"
tmux_tmpdir = "/from-file"
"#,
)
@@ -601,12 +319,7 @@ tmux_tmpdir = "/from-file"
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
token = "t"
[daemon]
daemon_url = "ws://x"
tmux_tmpdir = "/from-file"
"#,
)
@@ -619,7 +332,7 @@ tmux_tmpdir = "/from-file"
fn tmux_tmpdir_defaults_to_xdg_runtime_dir_when_unset() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
// dirs::runtime_dir() is `Some(_)` on any Linux host with
// $XDG_RUNTIME_DIR set (CI included). When it's `None`, the daemon
-300
View File
@@ -1,300 +0,0 @@
//! `maestrod configure` — interactive + flag-driven config bootstrap.
//!
//! Writes the unified Maestro config file at `~/.config/maestro/config.toml`
//! (or `$MAESTRO_CONFIG`) from a mix of CLI flags and (when stdin is a TTY)
//! prompts. Refuses to overwrite an existing file unless `--force` is passed.
//!
//! With `--force`, the daemon-relevant sections (`[shared]` and `[daemon]`)
//! are rewritten, but any `[cli]` / `[registry]` sections already in the file
//! are preserved.
use std::io::{self, BufRead, IsTerminal, Write};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use maestro_config::{DaemonSection, MaestroConfigFile, SharedSection};
use crate::config::{system_hostname, ConfigError, DEFAULT_BIND_ADDR};
#[derive(Debug, thiserror::Error)]
pub enum ConfigureError {
#[error("config: {0}")]
Config(#[from] ConfigError),
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("invalid bind_addr {input:?}: {source}")]
InvalidBindAddr {
input: String,
source: std::net::AddrParseError,
},
#[error("{path:?} already exists — pass --force to overwrite")]
AlreadyExists { path: PathBuf },
#[error(
"missing required field(s) in non-interactive mode: {missing} \
(pass via flags or run interactively)"
)]
MissingRequired { missing: String },
}
/// User-supplied inputs from flags. `None` means "not given on the CLI";
/// the configurator will prompt or default depending on context.
#[derive(Debug, Default, Clone)]
pub struct ConfigureInputs {
pub registry_url: Option<String>,
pub daemon_url: Option<String>,
pub token: Option<String>,
pub daemon_id: Option<String>,
pub bind_addr: Option<String>,
pub state_dir: Option<PathBuf>,
pub worktree_root: Option<PathBuf>,
pub hostname: Option<String>,
}
/// Run `maestrod configure`. Writes the file at `path` (creating parents).
///
/// `interactive` controls prompt behaviour:
/// - `true` → prompt for any required field that's missing from `inputs`.
/// - `false` → error out instead (used in scripts / containers).
///
/// Returns the path that was written.
pub fn run(
path: &Path,
inputs: ConfigureInputs,
interactive: bool,
force: bool,
) -> Result<PathBuf, ConfigureError> {
if path.exists() && !force {
return Err(ConfigureError::AlreadyExists {
path: path.to_path_buf(),
});
}
let stdin = io::stdin();
let stdout = io::stdout();
let mut handle = stdout.lock();
let interactive_ok = interactive && stdin.is_terminal();
let registry_url = resolve_required(
"registry_url",
"Registry base URL (e.g. https://maestro.example.com)",
inputs.registry_url,
&mut handle,
&stdin,
interactive_ok,
)?;
let daemon_url = resolve_required(
"daemon_url",
"Reachable URL for this daemon (e.g. ws://laptop.tail-abc.ts.net:7070)",
inputs.daemon_url,
&mut handle,
&stdin,
interactive_ok,
)?;
let token = resolve_required(
"token",
"Shared token (MAESTRO_TOKEN; paste from the registry)",
inputs.token,
&mut handle,
&stdin,
interactive_ok,
)?;
let host = inputs.hostname.unwrap_or_else(system_hostname);
let daemon_id = inputs.daemon_id.unwrap_or_else(|| host.clone());
let bind_addr = match inputs.bind_addr.as_deref() {
Some(raw) => Some(parse_bind_addr(raw)?),
None => None,
};
// Read-modify-write: preserve `[cli]` / `[registry]` sections that may
// already exist (e.g. on a host that runs both the registry and the
// daemon, or one that's been used as a CLI client).
let mut file = MaestroConfigFile::load_or_default(path).map_err(ConfigError::from)?;
file.shared = SharedSection {
registry_url: Some(registry_url),
token: Some(token),
};
file.daemon = DaemonSection {
daemon_id: Some(daemon_id),
hostname: Some(host),
daemon_url: Some(daemon_url),
bind_addr,
state_dir: inputs.state_dir,
worktree_root: inputs.worktree_root,
// `maestrod configure` doesn't prompt for perimeter fields yet — those
// are operator-edited in config.toml directly. Leave None on bootstrap.
perimeter_id: None,
private_addr: None,
// None ⇒ daemon falls back to pty::DEFAULT_REPLAY_BYTES. Override in
// config.toml as `[daemon] pty_replay_bytes = ...` (NAP-229).
pty_replay_bytes: None,
// None ⇒ daemon resolves to $XDG_RUNTIME_DIR at startup. Override in
// config.toml as `[daemon] tmux_tmpdir = "/tmp"` (NAP-234).
tmux_tmpdir: None,
// None ⇒ daemon defaults to DEFAULT_SPAWN_HISTORY_DAYS (30). Override
// in config.toml as `[daemon] spawn_history_days = 0` to disable (#24).
spawn_history_days: None,
};
file.save(path).map_err(ConfigError::from)?;
let _ = handle; // stdout handle only used during prompts; binary prints "wrote ..."
Ok(path.to_path_buf())
}
fn resolve_required(
field: &'static str,
prompt: &str,
provided: Option<String>,
out: &mut impl Write,
stdin: &io::Stdin,
interactive_ok: bool,
) -> Result<String, ConfigureError> {
if let Some(v) = provided.filter(|v| !v.is_empty()) {
return Ok(v);
}
if !interactive_ok {
return Err(ConfigureError::MissingRequired {
missing: field.to_string(),
});
}
write!(out, "{prompt}: ")?;
out.flush()?;
let mut line = String::new();
stdin.lock().read_line(&mut line)?;
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
return Err(ConfigureError::MissingRequired {
missing: field.to_string(),
});
}
Ok(trimmed)
}
fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigureError> {
raw.parse()
.map_err(|source| ConfigureError::InvalidBindAddr {
input: raw.to_string(),
source,
})
}
/// Suggest a sensible default bind address. Currently `DEFAULT_BIND_ADDR`
/// from `config.rs`; here so callers don't import both modules.
pub fn default_bind_addr_hint() -> &'static str {
DEFAULT_BIND_ADDR
}
#[cfg(test)]
mod tests {
use super::*;
fn full_inputs() -> ConfigureInputs {
ConfigureInputs {
registry_url: Some("https://reg.example".into()),
daemon_url: Some("ws://localhost:7070".into()),
token: Some("secret".into()),
daemon_id: Some("laptop".into()),
bind_addr: Some("0.0.0.0:7070".into()),
state_dir: None,
worktree_root: None,
hostname: Some("laptop.lan".into()),
}
}
#[test]
fn non_interactive_with_full_flags_writes_unified_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
run(&path, full_inputs(), false, false).unwrap();
let written = MaestroConfigFile::load(&path).unwrap();
assert_eq!(
written.shared.registry_url.as_deref(),
Some("https://reg.example")
);
assert_eq!(written.shared.token.as_deref(), Some("secret"));
assert_eq!(written.daemon.daemon_id.as_deref(), Some("laptop"));
assert_eq!(
written.daemon.bind_addr.unwrap().to_string(),
"0.0.0.0:7070"
);
}
#[test]
fn non_interactive_missing_required_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut inputs = full_inputs();
inputs.token = None;
let err = run(&path, inputs, false, false).unwrap_err();
match err {
ConfigureError::MissingRequired { missing } => assert_eq!(missing, "token"),
other => panic!("expected MissingRequired(token), got {other:?}"),
}
assert!(!path.exists());
}
#[test]
fn refuses_to_overwrite_without_force() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "existing").unwrap();
let err = run(&path, full_inputs(), false, false).unwrap_err();
assert!(matches!(err, ConfigureError::AlreadyExists { .. }));
// file untouched
assert_eq!(std::fs::read_to_string(&path).unwrap(), "existing");
}
#[test]
fn force_preserves_unrelated_sections() {
// Existing file has [cli] (set by the user) and [registry] (set by
// a co-located registry on the same host). `configure --force`
// should overwrite [shared] + [daemon] but leave those alone.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[cli]
cookie = "keep-me"
[registry]
bind_addr = "0.0.0.0:8080"
"#,
)
.unwrap();
run(&path, full_inputs(), false, true).unwrap();
let written = MaestroConfigFile::load(&path).unwrap();
assert_eq!(written.shared.token.as_deref(), Some("secret"));
assert_eq!(written.cli.cookie.as_deref(), Some("keep-me"));
assert_eq!(
written.registry.bind_addr,
Some("0.0.0.0:8080".parse().unwrap())
);
}
#[test]
fn invalid_bind_addr_flag_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut inputs = full_inputs();
inputs.bind_addr = Some("nope".into());
let err = run(&path, inputs, false, false).unwrap_err();
assert!(matches!(err, ConfigureError::InvalidBindAddr { .. }));
}
#[test]
fn daemon_id_defaults_to_hostname_when_neither_given() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let inputs = ConfigureInputs {
registry_url: Some("http://r".into()),
daemon_url: Some("ws://x".into()),
token: Some("t".into()),
hostname: Some("override-host".into()),
..ConfigureInputs::default()
};
run(&path, inputs, false, false).unwrap();
let written = MaestroConfigFile::load(&path).unwrap();
assert_eq!(written.daemon.daemon_id.as_deref(), Some("override-host"));
assert_eq!(written.daemon.hostname.as_deref(), Some("override-host"));
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
//! - relative `a/b` → home + `a/b`
//! - absolute `/a/b` → as-is
//!
//! The pure [`list_dir`] is unit-tested in isolation; the registry-client task
//! The pure [`list_dir`] is unit-tested in isolation; the HTTP handler
//! just wraps its `Result` in an `RpcResult` for the wire.
use std::path::{Path, PathBuf};
+1 -1
View File
@@ -12,7 +12,7 @@
//! to its worktree toplevel.
//!
//! It's a pure filesystem walk with no `git` subprocess — same shape as
//! [`crate::dirlist`]: unit-tested in isolation, and the registry-client task /
//! [`crate::dirlist`]: unit-tested in isolation, and the HTTP handler /
//! HTTP layer (#26) just wraps its `Option` for the wire.
use std::path::{Path, PathBuf};
-265
View File
@@ -1,265 +0,0 @@
//! HS256 JWT validation. Tokens are minted by the registry (NAP-215) and
//! validated locally by each daemon using the shared secret — no callback to
//! the registry on the attach hot path. Spec §Auth.
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use maestro_protocol::{JwtClaims, PairAttachClaims, REGISTRY_ISSUER};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum JwtError {
#[error("token decode failed: {0}")]
Decode(#[from] jsonwebtoken::errors::Error),
#[error("daemon_id mismatch (token wants `{wanted}`, daemon is `{ours}`)")]
WrongDaemon { wanted: String, ours: String },
#[error("session mismatch (token wants `{wanted}`, path is `{path}`)")]
WrongSession { wanted: Uuid, path: Uuid },
}
#[derive(Clone)]
pub struct JwtVerifier {
decoding: DecodingKey,
daemon_id: String,
}
impl std::fmt::Debug for JwtVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwtVerifier")
.field("daemon_id", &self.daemon_id)
.field("decoding", &"<redacted>")
.finish()
}
}
impl JwtVerifier {
pub fn new(secret: &str, daemon_id: impl Into<String>) -> Self {
Self {
decoding: DecodingKey::from_secret(secret.as_bytes()),
daemon_id: daemon_id.into(),
}
}
/// Decodes and validates the token. Returns the claims on success.
/// `exp` is enforced by `jsonwebtoken` with default 60s leeway.
pub fn verify(&self, token: &str) -> Result<JwtClaims, JwtError> {
let validation = Validation::new(Algorithm::HS256);
let data = decode::<JwtClaims>(token, &self.decoding, &validation)?;
if data.claims.daemon_id != self.daemon_id {
return Err(JwtError::WrongDaemon {
wanted: data.claims.daemon_id,
ours: self.daemon_id.clone(),
});
}
Ok(data.claims)
}
}
/// Mint a token. The daemon doesn't issue JWTs in production — this is here
/// for tests and for the future registry crate's convenience.
pub fn issue(secret: &str, claims: &JwtClaims) -> Result<String, jsonwebtoken::errors::Error> {
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, claims, &key)
}
/// Verifier for pair-scoped attach JWTs (NAP-247). Checks signature, `exp`,
/// `iss == REGISTRY_ISSUER`, `aud == self.daemon_id`, and `sid == session_id`
/// — i.e. the token was minted by the registry for *this* daemon and *this*
/// session, and hasn't expired. This is the session-binding guarantee the
/// network perimeter can't provide: a host the firewall admits still can't
/// attach to a session it wasn't handed, or replay a token to another
/// session/daemon.
///
/// It does **not** gate on the home daemon's identity (no `allow_inbound`
/// allow-list, NAP-339): the operator's perimeter/firewall is the trust
/// boundary for *which hosts may connect*, so every daemon on the perimeter is
/// equally trusted to attach. Re-introducing a per-pair gate later is a
/// verify-time check, not an architecture change.
#[derive(Clone)]
pub struct PairAttachVerifier {
decoding: DecodingKey,
daemon_id: String,
}
impl std::fmt::Debug for PairAttachVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PairAttachVerifier")
.field("daemon_id", &self.daemon_id)
.field("decoding", &"<redacted>")
.finish()
}
}
impl PairAttachVerifier {
pub fn new(secret: &str, daemon_id: impl Into<String>) -> Self {
Self {
decoding: DecodingKey::from_secret(secret.as_bytes()),
daemon_id: daemon_id.into(),
}
}
/// Validate the token for the path-bound `session_id`. On success returns
/// the home daemon's id (`sub`) for logging/tracing.
pub fn verify(&self, token: &str, session_id: Uuid) -> Result<String, JwtError> {
let mut validation = Validation::new(Algorithm::HS256);
// Require explicit audience match — the jsonwebtoken crate validates
// `aud` against this set on decode.
validation.set_audience(&[self.daemon_id.as_str()]);
// Bind to the registry issuer so a differently-purposed token signed
// with the same shared secret (e.g. an auth cookie) can't validate as a
// pair-attach credential (NAP-266).
validation.set_issuer(&[REGISTRY_ISSUER]);
let data = decode::<PairAttachClaims>(token, &self.decoding, &validation)?;
let claims = data.claims;
if claims.aud != self.daemon_id {
return Err(JwtError::WrongDaemon {
wanted: claims.aud,
ours: self.daemon_id.clone(),
});
}
if claims.sid != session_id {
return Err(JwtError::WrongSession {
wanted: claims.sid,
path: session_id,
});
}
Ok(claims.sub)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
use uuid::Uuid;
fn claims_for(daemon_id: &str, valid_seconds: i64) -> JwtClaims {
JwtClaims {
session_id: Uuid::new_v4(),
daemon_id: daemon_id.into(),
exp: (Utc::now() + Duration::seconds(valid_seconds)).timestamp(),
iat: Some(Utc::now().timestamp()),
}
}
#[test]
fn good_token_round_trip() {
let secret = "super-secret";
let v = JwtVerifier::new(secret, "laptop");
let c = claims_for("laptop", 300);
let token = issue(secret, &c).unwrap();
let back = v.verify(&token).unwrap();
assert_eq!(back.daemon_id, "laptop");
assert_eq!(back.session_id, c.session_id);
}
#[test]
fn rejects_wrong_daemon() {
let secret = "s";
let v = JwtVerifier::new(secret, "laptop");
let c = claims_for("dev-vm", 300);
let token = issue(secret, &c).unwrap();
let err = v.verify(&token).unwrap_err();
assert!(matches!(err, JwtError::WrongDaemon { .. }));
}
#[test]
fn rejects_expired() {
let secret = "s";
let v = JwtVerifier::new(secret, "laptop");
let c = claims_for("laptop", -120); // expired 2 minutes ago, beyond default 60s leeway
let token = issue(secret, &c).unwrap();
assert!(v.verify(&token).is_err());
}
#[test]
fn rejects_wrong_secret() {
let v = JwtVerifier::new("right", "laptop");
let token = issue("wrong", &claims_for("laptop", 300)).unwrap();
assert!(v.verify(&token).is_err());
}
// ----- PairAttachVerifier -----
fn pair_claims(home: &str, target: &str, sid: Uuid, valid_seconds: i64) -> PairAttachClaims {
PairAttachClaims {
iss: REGISTRY_ISSUER.into(),
sub: home.into(),
aud: target.into(),
sid,
exp: (Utc::now() + Duration::seconds(valid_seconds)).timestamp(),
}
}
fn issue_pair(secret: &str, claims: &PairAttachClaims) -> String {
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, claims, &key).unwrap()
}
#[test]
fn pair_verifier_accepts_registry_minted_token() {
let secret = "s";
let v = PairAttachVerifier::new(secret, "konishi");
let sid = Uuid::new_v4();
// The home daemon's identity is no longer gated (NAP-339) — any
// registry-minted, correctly-bound token for this daemon+session passes.
let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60));
let sub = v.verify(&token, sid).unwrap();
assert_eq!(sub, "laptop");
}
#[test]
fn pair_verifier_rejects_wrong_audience() {
let secret = "s";
let v = PairAttachVerifier::new(secret, "konishi");
let sid = Uuid::new_v4();
// Token addressed to a different target — must not pass our checks.
let token = issue_pair(secret, &pair_claims("laptop", "elsewhere", sid, 60));
assert!(v.verify(&token, sid).is_err());
}
#[test]
fn pair_verifier_rejects_foreign_issuer() {
// NAP-266: a token signed with the right secret but a non-registry
// `iss` (e.g. minted for some other purpose) must not validate as a
// pair-attach credential.
let secret = "s";
let v = PairAttachVerifier::new(secret, "konishi");
let sid = Uuid::new_v4();
let mut claims = pair_claims("laptop", "konishi", sid, 60);
claims.iss = "somebody-else".into();
let token = issue_pair(secret, &claims);
assert!(v.verify(&token, sid).is_err());
}
#[test]
fn pair_verifier_rejects_session_id_mismatch() {
let secret = "s";
let v = PairAttachVerifier::new(secret, "konishi");
let sid = Uuid::new_v4();
let path = Uuid::new_v4();
let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60));
assert!(matches!(
v.verify(&token, path).unwrap_err(),
JwtError::WrongSession { .. }
));
}
#[test]
fn pair_verifier_rejects_expired() {
let secret = "s";
let v = PairAttachVerifier::new(secret, "konishi");
let sid = Uuid::new_v4();
let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, -120));
assert!(v.verify(&token, sid).is_err());
}
#[test]
fn pair_verifier_rejects_wrong_secret() {
let v = PairAttachVerifier::new("right", "konishi");
let sid = Uuid::new_v4();
let token = issue_pair("wrong", &pair_claims("laptop", "konishi", sid, 60));
assert!(v.verify(&token, sid).is_err());
}
}
+5 -14
View File
@@ -1,37 +1,28 @@
//! `maestrod` — the per-host Maestro daemon.
//! `maestro daemon` — the per-host Maestro daemon.
//!
//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §Daemon for the
//! component's responsibilities. This library exposes the pieces; the binary
//! entry point in `src/main.rs` wires them together.
//! Wraps tmux, owns every session on the machine, and serves them to clients
//! (CLI, TUI, web) over a plain HTTP + WebSocket API on a local bind address.
//! No registry, no peers, no auth layer: reaching the bind address is the
//! authorization.
#![deny(missing_debug_implementations)]
pub mod activity;
pub mod attach;
pub mod broker;
pub mod catalog_mirror;
pub mod cli;
pub mod config;
pub mod configure;
pub mod dirlist;
pub mod gitroot;
pub mod jwt;
pub mod local_api;
pub mod local_events;
pub mod proc;
pub mod pty;
pub mod registry_client;
pub mod session;
pub mod sidecar;
pub mod spawn_history;
pub mod tmux;
pub mod worktree;
pub use broker::{BrokerError, BrokerHandle};
pub use catalog_mirror::CatalogMirror;
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
pub use configure::{run as run_configure, ConfigureError, ConfigureInputs};
pub use jwt::{issue as issue_jwt, JwtError, JwtVerifier, PairAttachVerifier};
pub use local_events::{spawn_session_publisher, LocalEventBus};
pub use registry_client::{ClientError, RegistryClient, HEARTBEAT_INTERVAL};
pub use session::{SessionError, SessionEvent, SessionManager};
+80 -392
View File
@@ -1,27 +1,14 @@
//! Daemon-side HTTP API for local clients (NAP-243).
//! Daemon-side HTTP API for clients (NAP-243).
//!
//! These routes live on the daemon's existing `bind_addr` (default
//! `127.0.0.1:7070`) alongside the legacy `/sessions/:id/stream` WS endpoint.
//! They're the contract `maestro-web` (NAP-241) and the rewired CLI (NAP-244)
//! call against — a same-origin, same-trust surface that doesn't need a
//! registry round-trip for catalog reads.
//! These routes live on the daemon's `bind_addr` (default `127.0.0.1:7070`).
//! They're the contract `maestro-web`, the TUI, and the CLI call against.
//!
//! ## Trust model (v1)
//! ## Trust model
//!
//! Whoever can reach `bind_addr` can use these routes. No JWT, no cookie. The
//! default `127.0.0.1` bind makes this same-trust as tmux (one-user-wide).
//! Operators who bind a perimeter address (LAN/WG IP) opt into one-segment-wide
//! trust — same model `maestro-web` documents per the connection-orchestration
//! spec. Cookie auth can be added later if a real use case demands it.
//!
//! ## Scope
//!
//! The daemon surfaces its own sessions plus the peer-catalog mirror (NAP-246).
//! Cross-daemon operations are brokered over the registry control link:
//! `WS /api/attach/:id` dials a peer over T2 (NAP-247), and `POST /api/sessions`
//! / `DELETE /api/sessions/:id` for a peer-owned target relay a create/kill RPC
//! to the owning daemon (NAP-327) — control-plane only, no session bytes
//! traverse the registry.
//! Operators who bind a LAN/VPN address opt into one-segment-wide trust — the
//! perimeter is theirs to choose, Maestro does not police it.
use std::sync::Arc;
@@ -32,27 +19,26 @@ use axum::routing::{any, delete, get};
use axum::{Json, Router};
use maestro_config::SpawnPreset;
use maestro_protocol::{
CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session,
PROTOCOL_VERSION,
CatalogEvent, CreateSessionRequest, ErrorInfo, GitRoot, Session, PROTOCOL_VERSION,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::catalog_mirror::CatalogMirror;
use crate::local_events::LocalEventBus;
use crate::session::SessionManager;
/// Daemon identity returned by `GET /api/web/info`. Browsers render this in the
/// header ("you're connected to konishi"); the field set mirrors `DaemonInfo`
/// plus the running daemon's protocol version and its spawn presets (#26).
/// Daemon identity returned by `GET /api/info`. Clients render this in the
/// header ("you're connected to konishi"), plus the daemon's protocol version
/// and its spawn presets (#26).
#[derive(Debug, Clone, Serialize)]
pub struct WebInfo {
pub daemon_id: String,
pub struct DaemonInfo {
pub hostname: String,
pub version: String,
pub daemon_url: String,
/// Home directory of the OS user this daemon runs as, so clients can
/// prefill a spawn form's working directory. Empty if unresolved.
pub home_dir: String,
/// Spawn-form command presets resolved from `[spawn].presets` (#22), with
/// the implicit `custom` tile appended. The web renders these as one-click
/// the implicit `custom` tile appended. Clients render these as one-click
/// tiles; it rides this existing payload so opening the form needs no extra
/// round-trip (#26).
pub presets: Vec<SpawnPreset>,
@@ -62,82 +48,48 @@ pub struct WebInfo {
pub struct LocalApiState {
pub manager: SessionManager,
pub hostname: Arc<String>,
pub daemon_url: Arc<String>,
/// Home directory of the OS user this daemon runs as, advertised so clients
/// can prefill a spawn form's working directory. Empty if unresolved.
pub home_dir: Arc<String>,
/// Resolved spawn presets (#22/#26) advertised in `GET /api/web/info`.
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
pub presets: Arc<Vec<SpawnPreset>>,
/// Peer-only mirror of the global catalog. Empty until the daemon's
/// registry client connects and receives the snapshot.
pub catalog: CatalogMirror,
/// Daemon-local fan-out of own + peer catalog deltas (NAP-248). The
/// `/api/events` WS subscribes here; everything else just reads REST.
/// Fan-out of session deltas (NAP-248). The `/api/events` WS subscribes
/// here; everything else just reads REST.
pub events: LocalEventBus,
/// Control link to the registry, used to broker cross-daemon create/kill
/// (NAP-327). When the link is down, brokered ops fail fast.
pub broker: BrokerHandle,
}
pub fn router(state: LocalApiState) -> Router {
Router::new()
.route("/api/sessions", get(list_sessions).post(create_session))
.route("/api/sessions/:id", delete(kill_session))
.route("/api/daemons", get(list_daemons))
.route("/api/daemons/:id/dirs", get(list_dir))
.route("/api/daemons/:id/recent-dirs", get(recent_dirs))
.route("/api/daemons/:id/gitroot", get(git_root))
.route("/api/web/info", get(web_info))
.route("/api/dirs", get(list_dir))
.route("/api/recent-dirs", get(recent_dirs))
.route("/api/gitroot", get(git_root))
.route("/api/info", get(daemon_info))
.route("/api/events", any(events_ws))
.route("/api/attach/:id", any(attach_ws))
.with_state(state)
}
async fn list_sessions(State(s): State<LocalApiState>) -> Json<Vec<Session>> {
let mut out = s.manager.snapshot().await;
out.extend(s.catalog.sessions().await);
Json(out)
Json(s.manager.snapshot().await)
}
async fn list_daemons(State(s): State<LocalApiState>) -> Json<Vec<DaemonInfo>> {
let self_info = DaemonInfo {
daemon_id: s.manager.daemon_id().to_string(),
hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(),
home_dir: (*s.home_dir).clone(),
};
let mut out = vec![self_info];
out.extend(s.catalog.daemons().await);
Json(out)
}
/// Query string for `GET /api/daemons/:id/dirs`. `path` is the directory to
/// list (the parent of what the user is typing); empty/`~`/relative are
/// resolved against the target daemon's home dir.
/// Query string for `GET /api/dirs`. `path` is the directory to list (the
/// parent of what the user is typing); empty/`~`/relative are resolved against
/// the daemon's home dir.
#[derive(Debug, Deserialize)]
struct DirQuery {
#[serde(default)]
path: String,
}
/// List one directory's immediate subdirectories on `daemon_id`'s host, for the
/// spawn form's working-directory autocomplete (NAP-329). Lists locally when
/// the target is this daemon; otherwise brokers a `ListDir` to the owning
/// daemon over the registry control link.
async fn list_dir(
State(s): State<LocalApiState>,
Path(daemon_id): Path<String>,
Query(q): Query<DirQuery>,
) -> Response {
if daemon_id == s.manager.daemon_id() {
return match crate::dirlist::list_dir(&s.home_dir, &q.path) {
Ok(listing) => Json(listing).into_response(),
Err(info) => error_response(dir_error_status(&info.code), &info.code, &info.message),
};
}
match s.broker.forward_list_dir(daemon_id, q.path).await {
Ok(listing) => Json::<DirListing>(listing).into_response(),
Err(e) => broker_error_response(&e),
/// List one directory's immediate subdirectories, for the spawn form's
/// working-directory autocomplete (NAP-329).
async fn list_dir(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) -> Response {
match crate::dirlist::list_dir(&s.home_dir, &q.path) {
Ok(listing) => Json(listing).into_response(),
Err(info) => error_response(dir_error_status(&info.code), &info.code, &info.message),
}
}
@@ -151,48 +103,27 @@ fn dir_error_status(code: &str) -> StatusCode {
}
}
/// Recent working dirs on `daemon_id`'s host for the spawn form's recents
/// picker (#26). Reads locally when the target is this daemon; otherwise brokers
/// a `RecentDirs` query to the owning daemon over the registry control link.
async fn recent_dirs(State(s): State<LocalApiState>, Path(daemon_id): Path<String>) -> Response {
if daemon_id == s.manager.daemon_id() {
let value = crate::spawn_history::to_wire(s.manager.recent_dirs());
return Json(value).into_response();
}
match s.broker.forward_recent_dirs(daemon_id).await {
Ok(recents) => Json::<RecentDirs>(recents).into_response(),
Err(e) => broker_error_response(&e),
}
/// Recent working dirs for the spawn form's recents picker (#26).
async fn recent_dirs(State(s): State<LocalApiState>) -> Response {
Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response()
}
/// Probe the enclosing git repo of `path` on `daemon_id`'s host, to infer the
/// worktree `base_repo` for the spawn form (#26). Local when the target is this
/// daemon; otherwise brokered to the owning daemon. `path` is resolved against
/// the target daemon's home dir (empty/`~`/relative), like [`list_dir`].
async fn git_root(
State(s): State<LocalApiState>,
Path(daemon_id): Path<String>,
Query(q): Query<DirQuery>,
) -> Response {
if daemon_id == s.manager.daemon_id() {
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
let value = GitRoot {
root: crate::gitroot::find_root(&dir),
};
return Json(value).into_response();
}
match s.broker.forward_git_root(daemon_id, q.path).await {
Ok(root) => Json::<GitRoot>(root).into_response(),
Err(e) => broker_error_response(&e),
}
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
/// (empty/`~`/relative), like [`list_dir`].
async fn git_root(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) -> Response {
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
Json(GitRoot {
root: crate::gitroot::find_root(&dir),
})
.into_response()
}
async fn web_info(State(s): State<LocalApiState>) -> Json<WebInfo> {
Json(WebInfo {
daemon_id: s.manager.daemon_id().to_string(),
async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
Json(DaemonInfo {
hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(),
home_dir: (*s.home_dir).clone(),
presets: (*s.presets).clone(),
})
}
@@ -201,14 +132,6 @@ async fn create_session(
State(s): State<LocalApiState>,
Json(body): Json<CreateSessionRequest>,
) -> Response {
if body.daemon_id != s.manager.daemon_id() {
// Cross-daemon create: broker it to the owning daemon over the registry
// control link (NAP-327). Control-plane only — the owner does the spawn.
return match s.broker.forward_create(body).await {
Ok(session) => (StatusCode::CREATED, Json(session)).into_response(),
Err(e) => broker_error_response(&e),
};
}
match s.manager.create(body).await {
Ok(session) => (StatusCode::CREATED, Json(session)).into_response(),
Err(e) => {
@@ -224,27 +147,11 @@ async fn create_session(
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
if !s.manager.has(id).await {
// Not ours — if a peer owns it, broker the kill to that daemon over the
// registry control link (NAP-327). Only a session no daemon knows about
// is a real 404.
let owner = s
.catalog
.sessions()
.await
.into_iter()
.find(|sess| sess.id == id)
.map(|sess| sess.daemon_id);
return match owner {
Some(daemon_id) => match s.broker.forward_kill(daemon_id, id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => broker_error_response(&e),
},
None => error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon or any known peer",
),
};
return error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon",
);
}
match s.manager.kill(id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
@@ -259,29 +166,6 @@ async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> R
}
}
/// Map a [`BrokerError`] from a forwarded create/kill (NAP-327) to a structured
/// HTTP error. Mirrors `broker_error_code`'s code points but also picks the
/// status the CLI/web client should react to.
fn broker_error_response(e: &BrokerError) -> Response {
let (code, message) = broker_error_code(e);
let status = match e {
// Registry link down or back-pressured: the home daemon can't broker
// right now — a retryable 503.
BrokerError::NoControlLink => StatusCode::SERVICE_UNAVAILABLE,
BrokerError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
BrokerError::ReplyClosed => StatusCode::BAD_GATEWAY,
// Registry- or owner-reported faults. Distinguish "nothing owns this"
// (404) from spawn/transport failures (502).
BrokerError::Registry(c, _) => match c.as_str() {
"unknown_session" => StatusCode::NOT_FOUND,
"daemon_not_connected" | "daemon_gone" => StatusCode::BAD_GATEWAY,
"rpc_timeout" => StatusCode::GATEWAY_TIMEOUT,
_ => StatusCode::BAD_GATEWAY,
},
};
error_response(status, code, &message)
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(
status,
@@ -297,31 +181,47 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
/// Convenience constructor — pulls the bits `LocalApiState` needs from
/// `DaemonConfig` so callers don't have to assemble it by hand.
#[allow(clippy::too_many_arguments)]
pub fn state_for(
manager: SessionManager,
hostname: impl Into<String>,
daemon_url: impl Into<String>,
home_dir: impl Into<String>,
presets: Vec<SpawnPreset>,
catalog: CatalogMirror,
events: LocalEventBus,
broker: BrokerHandle,
) -> LocalApiState {
LocalApiState {
manager,
hostname: Arc::new(hostname.into()),
daemon_url: Arc::new(daemon_url.into()),
home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets),
catalog,
events,
broker,
}
}
// ---------------------------------------------------------------------------
// WS /api/events — live catalog deltas for browser/CLI subscribers (NAP-248)
// WS /api/attach/:id — raw PTY bytes for one session
// ---------------------------------------------------------------------------
#[allow(clippy::result_large_err)]
async fn attach_ws(
Path(id): Path<Uuid>,
State(s): State<LocalApiState>,
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
if !s.manager.has(id).await {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon",
));
}
let manager = s.manager.clone();
Ok(ws.on_upgrade(move |socket| async move {
let _ = crate::attach::serve(socket, manager, id).await;
}))
}
// ---------------------------------------------------------------------------
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
// ---------------------------------------------------------------------------
async fn events_ws(
@@ -339,7 +239,6 @@ async fn pump_events(
) -> Result<(), axum::Error> {
use axum::extract::ws::Message;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::RegistryEvent;
use tokio::sync::broadcast::error::RecvError;
let (mut sink, mut stream) = socket.split();
@@ -349,18 +248,9 @@ async fn pump_events(
// idempotent for repeated *Added events, so any overlap is harmless.
let mut rx = s.events.subscribe();
let self_info = DaemonInfo {
daemon_id: s.manager.daemon_id().to_string(),
hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(),
home_dir: (*s.home_dir).clone(),
let snapshot = CatalogEvent::Snapshot {
sessions: s.manager.snapshot().await,
};
let mut daemons = vec![self_info];
daemons.extend(s.catalog.daemons().await);
let mut sessions = s.manager.snapshot().await;
sessions.extend(s.catalog.sessions().await);
let snapshot = RegistryEvent::Snapshot { daemons, sessions };
let text = serde_json::to_string(&snapshot).expect("snapshot encode");
if sink.send(Message::Text(text)).await.is_err() {
return Ok(());
@@ -371,7 +261,7 @@ async fn pump_events(
ev = rx.recv() => {
let event = match ev {
Ok(e) => e,
Err(RecvError::Lagged(n)) => RegistryEvent::Lagged { skipped: n },
Err(RecvError::Lagged(n)) => CatalogEvent::Lagged { skipped: n },
Err(RecvError::Closed) => break,
};
let text = serde_json::to_string(&event).expect("event encode");
@@ -393,205 +283,3 @@ async fn pump_events(
}
Ok(())
}
// ---------------------------------------------------------------------------
// Broker WS — `WS /api/attach/:session_id`
// ---------------------------------------------------------------------------
//
// Home-daemon broker (NAP-243 + NAP-247):
//
// 1. Session owned by self → delegate to local PTY subscribe.
// 2. Session owned by a peer daemon → ResolveAttach via the registry, dial
// the peer over T2, proxy frames.
// 3. Unknown session → 404.
use crate::broker::{BrokerError, BrokerHandle};
#[derive(Debug, Clone)]
pub struct BrokerState {
pub manager: SessionManager,
pub catalog: CatalogMirror,
pub broker: BrokerHandle,
pub self_daemon_id: Arc<String>,
pub self_perimeter_id: Option<Arc<String>>,
}
pub fn broker_router(state: BrokerState) -> Router {
Router::new()
.route("/api/attach/:id", any(broker_attach_ws))
.with_state(state)
}
async fn broker_attach_ws(
Path(id): Path<Uuid>,
State(state): State<BrokerState>,
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
if state.manager.has(id).await {
// Local short-circuit.
let manager = state.manager.clone();
return Ok(ws.on_upgrade(move |socket| async move {
let _ = crate::attach::serve_local(socket, manager, id).await;
}));
}
// Look up which peer owns the session via the catalog mirror.
let peer = state
.catalog
.sessions()
.await
.into_iter()
.find(|s| s.id == id)
.map(|s| s.daemon_id);
let Some(peer_daemon_id) = peer else {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon or any known peer",
));
};
// ResolveAttach via the control link.
let candidates = match state.broker.resolve_attach(id).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(%id, %peer_daemon_id, error = %e, "ResolveAttach failed");
let (code, msg) = broker_error_code(&e);
return Err(error_response(StatusCode::BAD_GATEWAY, code, &msg));
}
};
// Pick a transport. T1 is canceled (NAP-235); T3 is gated (NAP-238).
// For T2 we need both daemons to declare the same perimeter id, and the
// candidate must include a private address.
let perimeter = candidates.perimeter.clone();
let our_perimeter = state.self_perimeter_id.as_deref().map(|s| s.as_str());
let dial_url = match (perimeter, our_perimeter) {
(Some(p), Some(ours)) if p.perimeter_id == ours => {
format!(
"ws://{}/sessions/{}/stream?token={}",
p.private_addr,
id,
urlencoding::encode(&candidates.jwt),
)
}
(Some(_), Some(_)) => {
return Err(error_response(
StatusCode::BAD_GATEWAY,
"perimeter_mismatch",
"peer is on a different perimeter; T2 unavailable, T1/T3 not implemented",
));
}
(Some(_), None) => {
return Err(error_response(
StatusCode::BAD_GATEWAY,
"no_local_perimeter",
"this daemon has no perimeter_id set; can't use T2",
));
}
(None, _) => {
return Err(error_response(
StatusCode::BAD_GATEWAY,
"no_perimeter_candidate",
"peer hasn't advertised a perimeter; T2 unavailable",
));
}
};
// Dial the peer BEFORE completing the upgrade. A failure here — peer down,
// perimeter-side rejection, or the peer's verifier refusing the pair JWT
// (all of which the peer reports pre-upgrade) — then surfaces as a
// structured `502` the client can act on, instead of an opaque
// post-upgrade close indistinguishable from a clean session end (NAP-265).
let upstream = match tokio_tungstenite::connect_async(&dial_url).await {
Ok((up, _)) => up,
Err(e) => {
tracing::warn!(%id, url = %dial_url, error = %e, "T2 dial to peer failed");
return Err(error_response(
StatusCode::BAD_GATEWAY,
"peer_unreachable",
&format!("could not open the data-plane connection to the session's daemon: {e}"),
));
}
};
Ok(ws.on_upgrade(move |socket| async move {
let _ = pump_broker_ws(socket, upstream).await;
}))
}
/// Bidirectional pump between the client WS and an already-connected peer WS.
async fn pump_broker_ws(
client: axum::extract::ws::WebSocket,
upstream: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> Result<(), axum::Error> {
use axum::extract::ws::Message as AxMessage;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as WsMessage;
let (mut client_sink, mut client_stream) = client.split();
let (mut up_sink, mut up_stream) = upstream.split();
let client_to_up = async {
while let Some(Ok(msg)) = client_stream.next().await {
let frame = match msg {
AxMessage::Text(t) => WsMessage::Text(t),
AxMessage::Binary(b) => WsMessage::Binary(b),
AxMessage::Ping(p) => WsMessage::Ping(p),
AxMessage::Pong(p) => WsMessage::Pong(p),
AxMessage::Close(_) => break,
};
if up_sink.send(frame).await.is_err() {
break;
}
}
};
let up_to_client = async {
while let Some(Ok(msg)) = up_stream.next().await {
let frame = match msg {
WsMessage::Text(t) => AxMessage::Text(t),
WsMessage::Binary(b) => AxMessage::Binary(b),
WsMessage::Ping(p) => AxMessage::Ping(p),
WsMessage::Pong(p) => AxMessage::Pong(p),
WsMessage::Close(_) | WsMessage::Frame(_) => break,
};
if client_sink.send(frame).await.is_err() {
break;
}
}
};
tokio::select! {
_ = client_to_up => {}
_ = up_to_client => {}
}
Ok(())
}
fn broker_error_code(e: &BrokerError) -> (&'static str, String) {
match e {
BrokerError::NoControlLink => (
"no_registry_link",
"this daemon is not connected to the registry".into(),
),
BrokerError::ReplyClosed => ("reply_closed", e.to_string()),
BrokerError::Timeout(_) => ("resolve_timeout", e.to_string()),
BrokerError::Registry(code, msg) => (
// Code points are stable across the protocol so the client can
// distinguish "unknown_session" from "self_target" etc.
match code.as_str() {
"unknown_session" => "unknown_session",
"self_target" => "self_target",
"jwt_failed" => "jwt_failed",
// Forwarded create/kill transport faults (NAP-327).
"daemon_not_connected" => "daemon_not_connected",
"daemon_gone" => "daemon_gone",
"rpc_timeout" => "rpc_timeout",
_ => "registry_error",
},
msg.clone(),
),
}
}
+22 -39
View File
@@ -1,37 +1,26 @@
//! Daemon-local fan-out of `RegistryEvent`s for the `WS /api/events`
//! endpoint (NAP-248).
//! Fan-out of session deltas for the `WS /api/events` endpoint (NAP-248).
//!
//! Two sources feed this bus:
//!
//! 1. **Own-session events** — the daemon's `SessionManager::events()` stream,
//! translated from `SessionEvent` into the matching `RegistryEvent` variant
//! by [`spawn_session_publisher`].
//! 2. **Peer-catalog events** — applied to the daemon's [`CatalogMirror`] when
//! the registry fans them out (NAP-246). `CatalogMirror::apply` publishes
//! each event here after mutating its local view.
//!
//! Subscribers get the merged stream. The `WS /api/events` handler builds a
//! one-shot `Snapshot` of own ∪ peer state on connect, then forwards live
//! deltas until the client disconnects. `Lagged` follows the registry's
//! existing convention — clients resync via REST.
//! [`spawn_session_publisher`] translates the `SessionManager::events()` stream
//! from `SessionEvent` into the matching [`CatalogEvent`] variant and publishes
//! it here. The `WS /api/events` handler sends a one-shot `Snapshot` on connect,
//! then forwards live deltas until the client disconnects. `Lagged` tells
//! clients to resync via REST.
use std::sync::Arc;
use maestro_protocol::RegistryEvent;
use maestro_protocol::CatalogEvent;
use tokio::sync::broadcast;
use crate::session::{SessionEvent, SessionManager};
/// Capacity of the per-bus broadcast channel. Sourced from the shared
/// [`maestro_config::BroadcastTuning`], which the registry's
/// `EVENT_CHANNEL_CAPACITY` also tracks, so a slow client behaves the same on
/// either side (NAP-273).
/// [`maestro_config::BroadcastTuning`] (NAP-273).
const BUS_CAPACITY: usize = maestro_config::BroadcastTuning::DEFAULT.events_capacity;
/// Cheap-to-clone handle around a `broadcast::Sender<RegistryEvent>`.
/// Cheap-to-clone handle around a `broadcast::Sender<CatalogEvent>`.
#[derive(Debug, Clone)]
pub struct LocalEventBus {
sender: Arc<broadcast::Sender<RegistryEvent>>,
sender: Arc<broadcast::Sender<CatalogEvent>>,
}
impl LocalEventBus {
@@ -43,11 +32,11 @@ impl LocalEventBus {
}
/// Push an event onto the bus. No-op if there are no subscribers.
pub fn publish(&self, event: RegistryEvent) {
pub fn publish(&self, event: CatalogEvent) {
let _ = self.sender.send(event);
}
pub fn subscribe(&self) -> broadcast::Receiver<RegistryEvent> {
pub fn subscribe(&self) -> broadcast::Receiver<CatalogEvent> {
self.sender.subscribe()
}
}
@@ -59,28 +48,24 @@ impl Default for LocalEventBus {
}
/// Spawn a task that converts each `SessionEvent` from `manager` into the
/// matching `RegistryEvent` and publishes it on `bus`. Exits when the
/// matching `CatalogEvent` and publishes it on `bus`. Exits when the
/// `SessionManager` is dropped (the events channel closes).
pub fn spawn_session_publisher(manager: SessionManager, bus: LocalEventBus) {
let daemon_id = manager.daemon_id().to_string();
tokio::spawn(async move {
let mut rx = manager.events();
loop {
match rx.recv().await {
Ok(SessionEvent::Added(session)) => {
bus.publish(RegistryEvent::SessionAdded { session });
bus.publish(CatalogEvent::SessionAdded { session });
}
Ok(SessionEvent::Updated(session)) => {
bus.publish(RegistryEvent::SessionUpdated { session });
bus.publish(CatalogEvent::SessionUpdated { session });
}
Ok(SessionEvent::Removed(id)) => {
bus.publish(RegistryEvent::SessionRemoved {
daemon_id: daemon_id.clone(),
id,
});
bus.publish(CatalogEvent::SessionRemoved { id });
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
bus.publish(RegistryEvent::Lagged { skipped });
bus.publish(CatalogEvent::Lagged { skipped });
}
Err(broadcast::error::RecvError::Closed) => return,
}
@@ -96,9 +81,9 @@ mod tests {
async fn publish_visible_to_subscriber() {
let bus = LocalEventBus::new();
let mut rx = bus.subscribe();
bus.publish(RegistryEvent::Lagged { skipped: 7 });
bus.publish(CatalogEvent::Lagged { skipped: 7 });
match rx.recv().await {
Ok(RegistryEvent::Lagged { skipped }) => assert_eq!(skipped, 7),
Ok(CatalogEvent::Lagged { skipped }) => assert_eq!(skipped, 7),
other => panic!("unexpected: {other:?}"),
}
}
@@ -107,9 +92,7 @@ mod tests {
async fn no_subscribers_swallows_event() {
let bus = LocalEventBus::new();
// No one's listening — `publish` must not panic and must not block.
bus.publish(RegistryEvent::DaemonRemoved {
daemon_id: "ghost".into(),
});
bus.publish(CatalogEvent::Lagged { skipped: 1 });
}
#[tokio::test]
@@ -117,9 +100,9 @@ mod tests {
let a = LocalEventBus::new();
let b = a.clone();
let mut rx = a.subscribe();
b.publish(RegistryEvent::Lagged { skipped: 3 });
b.publish(CatalogEvent::Lagged { skipped: 3 });
match rx.recv().await {
Ok(RegistryEvent::Lagged { skipped }) => assert_eq!(skipped, 3),
Ok(CatalogEvent::Lagged { skipped }) => assert_eq!(skipped, 3),
other => panic!("unexpected: {other:?}"),
}
}
@@ -1,773 +0,0 @@
//! Outbound WebSocket client to the always-on registry.
//!
//! Holds a single persistent connection. On disconnect, reconnects with
//! exponential backoff. Per connection:
//!
//! 1. Send `Hello { daemon_id, hostname, version, token }`.
//! 2. Send `SessionsSnapshot { sessions: snapshot() }`.
//! 3. Drive a `select!` loop that pumps:
//! - inbound `CreateSession` / `KillSession` → `SessionManager` → reply
//! - outbound `SessionAdded/Removed/Updated` from the local event bus,
//! except the event that an in-flight RPC reply already conveys, which is
//! swallowed so the registry writes each change once (see [`RpcCovered`]).
//! - a 10s `Heartbeat` ticker.
use std::collections::HashSet;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
DaemonInbound, DaemonOutbound, PerimeterCandidate, RpcResult, PROTOCOL_VERSION,
};
use tokio::sync::broadcast::error::RecvError;
use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
use tokio::sync::mpsc;
use crate::broker::BrokerHandle;
use crate::catalog_mirror::CatalogMirror;
use crate::session::{SessionEvent, SessionManager};
/// Per-message heartbeat interval. Less than the registry's 30s timeout, with
/// headroom for jitter. Spec §"Wire protocol (D↔R)".
pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
/// Bounds on the reconnect backoff.
const BACKOFF_MIN: Duration = Duration::from_millis(250);
const BACKOFF_MAX: Duration = Duration::from_secs(30);
/// Session ids whose next lifecycle event the registry already learns about via
/// an RPC reply, so forwarding the matching async `SessionAdded` /
/// `SessionRemoved` would be a redundant *second* write to the registry catalog
/// (NAP-268).
///
/// A registry-driven `CreateSession` / `KillSession` is reported back to the
/// registry in two ways: the `CreateSessionResult` / `KillSessionResult` reply
/// (which the registry applies eagerly), *and* the async lifecycle event this
/// daemon emits on its own `SessionManager` stream. Both used to land, so the
/// registry's catalog write had to be idempotent to absorb the duplicate. We
/// drop the duplicate at the source instead: when an RPC creates or kills a
/// session, its id is parked here and the matching async event is swallowed
/// (consumed exactly once) before it reaches the registry. Spontaneous changes
/// — sessions created via the daemon's local HTTP API, or reaped on their own —
/// are not parked and still forward normally, since the registry has no other
/// way to learn about them.
///
/// Tracked per-connection: a reconnect starts fresh (the post-handshake
/// `SessionsSnapshot` reconciles the registry's full set anyway).
#[derive(Debug, Default)]
struct RpcCovered {
added: HashSet<Uuid>,
removed: HashSet<Uuid>,
}
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("ws error: {0}")]
Ws(#[from] tokio_tungstenite::tungstenite::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("registry handshake failed: {0}")]
Handshake(String),
}
#[derive(Debug)]
pub struct RegistryClient {
registry_url: String,
daemon_id: String,
hostname: String,
daemon_url: String,
home_dir: String,
token: String,
/// Operator-set perimeter (id + reachable address) advertised in heartbeats
/// so peer daemons can dial this one over T2 when they share the same
/// perimeter. Typed so "both or neither" holds by construction (NAP-272).
perimeter: Option<PerimeterCandidate>,
manager: SessionManager,
/// Local mirror of the global catalog (peer daemons + peer sessions),
/// populated from `DaemonInbound::CatalogEvent` frames the registry
/// fans out.
catalog: CatalogMirror,
/// Shared with HTTP handlers (NAP-247): when one calls `resolve_attach`,
/// we route the request through this client's control link and route the
/// reply back via the handle's pending map.
broker: BrokerHandle,
/// Outbound channel from `BrokerHandle::resolve_attach` (and future broker
/// requests). Drained inside `connect_once` and forwarded into the WS.
broker_rx: mpsc::Receiver<DaemonOutbound>,
}
impl RegistryClient {
#[allow(clippy::too_many_arguments)]
pub fn new(
registry_url: impl Into<String>,
daemon_id: impl Into<String>,
hostname: impl Into<String>,
daemon_url: impl Into<String>,
home_dir: impl Into<String>,
token: impl Into<String>,
manager: SessionManager,
) -> Self {
Self::new_with_bus(
registry_url,
daemon_id,
hostname,
daemon_url,
home_dir,
token,
manager,
None,
)
}
/// Variant that wires a [`LocalEventBus`](crate::LocalEventBus) into the
/// catalog mirror so peer deltas reach `WS /api/events` (NAP-248).
#[allow(clippy::too_many_arguments)]
pub fn new_with_bus(
registry_url: impl Into<String>,
daemon_id: impl Into<String>,
hostname: impl Into<String>,
daemon_url: impl Into<String>,
home_dir: impl Into<String>,
token: impl Into<String>,
manager: SessionManager,
bus: Option<crate::LocalEventBus>,
) -> Self {
let (broker, broker_rx) = BrokerHandle::new();
let catalog = match &bus {
Some(b) => CatalogMirror::new().with_bus(b.clone()),
None => CatalogMirror::new(),
};
Self {
registry_url: registry_url.into(),
daemon_id: daemon_id.into(),
hostname: hostname.into(),
daemon_url: daemon_url.into(),
home_dir: home_dir.into(),
token: token.into(),
perimeter: None,
manager,
catalog,
broker,
broker_rx,
}
}
/// Cheap clone of the broker handle for HTTP handlers (NAP-247). Sends
/// here are pumped through this client's control link.
pub fn broker(&self) -> BrokerHandle {
self.broker.clone()
}
/// Cheap handle to the daemon's peer-catalog mirror. Clone freely;
/// internal state is `Arc<Mutex<…>>`.
pub fn catalog(&self) -> CatalogMirror {
self.catalog.clone()
}
/// Set the perimeter advertised in heartbeats. The typed argument encodes
/// "both id and addr, or neither" — a private address without a perimeter
/// id is no longer representable (NAP-272).
pub fn with_perimeter(mut self, perimeter: Option<PerimeterCandidate>) -> Self {
self.perimeter = perimeter;
self
}
/// Run forever, reconnecting with exponential backoff between attempts.
/// Returns only if cancelled by the caller dropping the task.
pub async fn run(mut self) {
let mut backoff = BACKOFF_MIN;
loop {
let outcome = self.connect_once().await;
// Whatever happened, the link is down now — make the broker fail
// fast until the next successful handshake re-marks it up (NAP-263).
self.broker.set_connected(false);
match outcome {
Ok(()) => {
tracing::info!("registry connection ended cleanly");
backoff = BACKOFF_MIN;
}
Err(e) => {
tracing::warn!(error = %e, ?backoff, "registry connection failed");
}
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(BACKOFF_MAX);
}
}
async fn connect_once(&mut self) -> Result<(), ClientError> {
let (ws, _) = tokio_tungstenite::connect_async(&self.registry_url).await?;
let (mut sink, mut stream) = ws.split();
// Hello + wait for Ack
let hello = DaemonOutbound::Hello {
daemon_id: self.daemon_id.clone(),
hostname: self.hostname.clone(),
version: PROTOCOL_VERSION.into(),
token: self.token.clone(),
daemon_url: self.daemon_url.clone(),
home_dir: self.home_dir.clone(),
};
sink.send(Message::Text(serde_json::to_string(&hello)?))
.await?;
match stream.next().await {
Some(Ok(Message::Text(t))) => match serde_json::from_str::<DaemonInbound>(&t)? {
DaemonInbound::Ack => {}
DaemonInbound::Error(e) => {
return Err(ClientError::Handshake(format!("{}: {}", e.code, e.message)));
}
other => {
return Err(ClientError::Handshake(format!(
"unexpected first frame {other:?}"
)))
}
},
other => {
return Err(ClientError::Handshake(format!(
"no hello-ack frame: {other:?}"
)))
}
}
// Link is up — let the broker queue ResolveAttach frames again
// (NAP-263). Cleared in `run` once this connection ends.
self.broker.set_connected(true);
// Subscribe to the session-event stream BEFORE taking the snapshot. A
// reap or kill that races the handshake would otherwise slip between
// the snapshot and the subscription — present in neither — leaving the
// registry advertising a session this daemon no longer has until the
// next reconnect. `local_api::pump_events` subscribes-then-snapshots
// for the browser/CLI event WS for exactly this reason.
let mut events = self.manager.events();
let snapshot = DaemonOutbound::SessionsSnapshot {
sessions: self.manager.snapshot().await,
};
sink.send(Message::Text(serde_json::to_string(&snapshot)?))
.await?;
// The first interval tick fires immediately, so the registry gets a
// perimeter advertisement (NAP-247) right after the handshake instead
// of waiting a full HEARTBEAT_INTERVAL for `update_perimeter` to land.
let mut hb = tokio::time::interval(HEARTBEAT_INTERVAL);
hb.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// Session ids whose async lifecycle event the in-flight RPC reply
// already conveys to the registry — swallowed here so the registry
// writes each change exactly once (NAP-268).
let mut covered = RpcCovered::default();
loop {
tokio::select! {
msg = stream.next() => {
match msg {
None => break,
Some(Err(e)) => return Err(e.into()),
Some(Ok(Message::Close(_))) => break,
Some(Ok(Message::Text(text))) => {
self.handle_inbound(&text, &mut sink, &mut covered).await?;
}
Some(Ok(_)) => {}
}
}
ev = events.recv() => {
if !forward_session_event(&self.manager, ev, &mut sink, &mut covered).await? {
break;
}
}
_ = hb.tick() => {
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::Heartbeat {
perimeter: self.perimeter.clone(),
},
)?)).await?;
}
Some(broker_msg) = self.broker_rx.recv() => {
// Outbound broker requests (NAP-247) — e.g. ResolveAttach.
sink.send(Message::Text(serde_json::to_string(&broker_msg)?))
.await?;
}
}
}
Ok(())
}
async fn handle_inbound<S>(
&self,
text: &str,
sink: &mut S,
covered: &mut RpcCovered,
) -> Result<(), ClientError>
where
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
{
let parsed: DaemonInbound = match serde_json::from_str(text) {
Ok(p) => p,
Err(e) => {
// A single malformed frame must not drop the registry link —
// log and skip it (NAP-269).
tracing::warn!(error = %e, "malformed registry frame — skipping");
return Ok(());
}
};
match parsed {
DaemonInbound::Ack => {}
DaemonInbound::Error(e) => {
tracing::warn!(code = %e.code, message = %e.message, "registry-reported error");
}
DaemonInbound::CreateSession { reply_to, request } => {
let result = match self.manager.create(request).await {
Ok(s) => {
// The reply carries the session, so the registry applies
// it on receipt; swallow the async `SessionAdded` this
// create just queued on our event stream (NAP-268).
covered.added.insert(s.id);
RpcResult::Ok { value: s }
}
Err(e) => RpcResult::Err {
error: e.to_error_info(),
},
};
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::CreateSessionResult { reply_to, result },
)?))
.await?;
}
DaemonInbound::KillSession { reply_to, id } => {
let result = match self.manager.kill(id).await {
Ok(()) => {
// The reply confirms the removal; swallow the async
// `SessionRemoved` it queued so the registry drops the
// session exactly once (NAP-268).
covered.removed.insert(id);
RpcResult::Ok { value: () }
}
Err(e) => RpcResult::Err {
error: e.to_error_info(),
},
};
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::KillSessionResult { reply_to, result },
)?))
.await?;
}
DaemonInbound::ResolveAttachResult { reply_to, result } => {
self.broker.complete_resolve(reply_to, result).await;
}
DaemonInbound::ForwardCreateResult { reply_to, result } => {
self.broker.complete_forward_create(reply_to, result).await;
}
DaemonInbound::ForwardKillResult { reply_to, result } => {
self.broker.complete_forward_kill(reply_to, result).await;
}
DaemonInbound::ListDir { reply_to, path } => {
// List a directory on this host for a client's spawn-form
// autocomplete (NAP-329). Resolved against this daemon's home.
let result = match crate::dirlist::list_dir(&self.home_dir, &path) {
Ok(listing) => RpcResult::Ok { value: listing },
Err(error) => RpcResult::Err { error },
};
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::ListDirResult { reply_to, result },
)?))
.await?;
}
DaemonInbound::ForwardListDirResult { reply_to, result } => {
self.broker
.complete_forward_list_dir(reply_to, result)
.await;
}
DaemonInbound::RecentDirs { reply_to } => {
// Answer a peer's recent-dirs query from this host's spawn
// history (#26). Daemon-wide, so no path to resolve.
let value = crate::spawn_history::to_wire(self.manager.recent_dirs());
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::RecentDirsResult {
reply_to,
result: RpcResult::Ok { value },
},
)?))
.await?;
}
DaemonInbound::ForwardRecentDirsResult { reply_to, result } => {
self.broker
.complete_forward_recent_dirs(reply_to, result)
.await;
}
DaemonInbound::GitRoot { reply_to, path } => {
// Probe the enclosing repo of `path` on this host (#26),
// resolving the abbreviated path against this daemon's home the
// same way `ListDir` does.
let dir = crate::dirlist::resolve(&self.home_dir, &path);
let value = maestro_protocol::GitRoot {
root: crate::gitroot::find_root(&dir),
};
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::GitRootResult {
reply_to,
result: RpcResult::Ok { value },
},
)?))
.await?;
}
DaemonInbound::ForwardGitRootResult { reply_to, result } => {
self.broker
.complete_forward_git_root(reply_to, result)
.await;
}
DaemonInbound::RelaySetup { .. } => {
// T3 (relay) is gated on NAP-238. Lands here only so newer
// registries sending it don't crash this daemon on parse.
tracing::debug!("RelaySetup received but T3 not implemented (NAP-238 pending)");
}
DaemonInbound::CatalogEvent { event } => {
// NAP-246: peer-filtered catalog delta from the registry.
self.catalog.apply(event).await;
}
DaemonInbound::Unknown => {
// A newer registry emitted a frame type this build doesn't know.
// Skip it rather than dropping the link (NAP-269).
tracing::debug!("unknown registry frame — skipping (forward-compat, NAP-269)");
}
}
Ok(())
}
}
/// Translate one item from the session-event stream into an outbound frame on
/// `sink`. Returns `Ok(false)` once the stream has closed and the caller
/// should stop the loop.
///
/// An `Added` / `Removed` whose id is parked in `covered` was driven by an RPC
/// whose reply already reports the change to the registry; it is consumed and
/// *not* forwarded, so the registry writes each change exactly once (NAP-268).
///
/// On `Lagged` the daemon's own view of its sessions skipped frames — which
/// may include a `Removed`. Emitting nothing (the previous behaviour) left the
/// registry advertising a now-dead session until the next reconnect, since the
/// registry only reconciles a daemon's full session set on `apply_snapshot`.
/// So we re-send a full `SessionsSnapshot`, which the registry applies by
/// replacing this daemon's set — healing the missed removal immediately. The
/// snapshot is the full truth, so any still-parked `covered` ids are stale and
/// cleared.
async fn forward_session_event<S>(
manager: &SessionManager,
ev: Result<SessionEvent, RecvError>,
sink: &mut S,
covered: &mut RpcCovered,
) -> Result<bool, ClientError>
where
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
{
let frame = match ev {
Ok(SessionEvent::Added(session)) => {
if covered.added.remove(&session.id) {
return Ok(true);
}
DaemonOutbound::SessionAdded { session }
}
Ok(SessionEvent::Removed(id)) => {
if covered.removed.remove(&id) {
return Ok(true);
}
DaemonOutbound::SessionRemoved { id }
}
Ok(SessionEvent::Updated(session)) => DaemonOutbound::SessionUpdated { session },
Err(RecvError::Lagged(skipped)) => {
tracing::warn!(
skipped,
"session-event stream lagged — resyncing registry with a full snapshot"
);
covered.added.clear();
covered.removed.clear();
DaemonOutbound::SessionsSnapshot {
sessions: manager.snapshot().await,
}
}
Err(RecvError::Closed) => return Ok(false),
};
sink.send(Message::Text(serde_json::to_string(&frame)?))
.await?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::pin::Pin;
use std::task::{Context, Poll};
use uuid::Uuid;
/// Minimal in-memory sink that records every frame sent to it, so we can
/// assert exactly what `forward_session_event` emits.
#[derive(Default)]
struct VecSink(Vec<Message>);
impl futures_util::Sink<Message> for VecSink {
type Error = tokio_tungstenite::tungstenite::Error;
fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
self.get_mut().0.push(item);
Ok(())
}
fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
}
/// A `SessionManager` backed by temp dirs. It never spawns tmux here — the
/// tests only drive `forward_session_event`, which at most reads the
/// (empty) in-memory session map via `snapshot()`.
fn test_manager() -> (SessionManager, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
"test-daemon",
crate::tmux::Tmux::new(),
crate::sidecar::SidecarStore::new(dir.path().join("sessions")),
crate::worktree::WorktreeManager::new(dir.path().join("worktrees")),
dir.path().join("fifos"),
);
(mgr, dir)
}
fn decode(sink: &VecSink) -> Vec<DaemonOutbound> {
sink.0
.iter()
.map(|m| match m {
Message::Text(t) => serde_json::from_str(t).unwrap(),
other => panic!("unexpected frame {other:?}"),
})
.collect()
}
fn sample_session(id: Uuid) -> maestro_protocol::Session {
maestro_protocol::Session {
id,
daemon_id: "test-daemon".into(),
cwd: std::path::PathBuf::from("/tmp"),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
status: maestro_protocol::SessionStatus::Running,
created_at: chrono::Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
// Regression test for NAP-291 (defect B): a lagged event stream may have
// dropped a `Removed`, so the daemon must resync the registry's full set
// rather than silently skip — otherwise a dead session lingers in the
// catalog until the next reconnect. The previous `continue` sent nothing.
#[tokio::test]
async fn lagged_stream_resyncs_with_full_snapshot() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let cont = forward_session_event(
&mgr,
Err(RecvError::Lagged(9)),
&mut sink,
&mut RpcCovered::default(),
)
.await
.unwrap();
assert!(cont, "lag is recoverable; the loop must continue");
let frames = decode(&sink);
assert!(
matches!(frames.as_slice(), [DaemonOutbound::SessionsSnapshot { .. }]),
"a lagged stream must resync via SessionsSnapshot; got {frames:?}",
);
}
#[tokio::test]
async fn removed_event_forwards_session_removed() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let id = Uuid::new_v4();
let cont = forward_session_event(
&mgr,
Ok(SessionEvent::Removed(id)),
&mut sink,
&mut RpcCovered::default(),
)
.await
.unwrap();
assert!(cont);
let frames = decode(&sink);
assert!(
matches!(frames.as_slice(), [DaemonOutbound::SessionRemoved { id: got }] if *got == id),
"got {frames:?}",
);
}
#[tokio::test]
async fn closed_stream_signals_break_without_sending() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let cont = forward_session_event(
&mgr,
Err(RecvError::Closed),
&mut sink,
&mut RpcCovered::default(),
)
.await
.unwrap();
assert!(!cont, "a closed stream must signal the loop to break");
assert!(sink.0.is_empty(), "nothing should be sent on close");
}
// NAP-268: an RPC-driven create/kill reports the change to the registry via
// its reply, so the matching async lifecycle event must be swallowed — never
// forwarded — to keep the registry's catalog write single (not idempotent).
#[tokio::test]
async fn rpc_covered_added_is_swallowed_once() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let id = Uuid::new_v4();
let mut covered = RpcCovered::default();
covered.added.insert(id);
let cont = forward_session_event(
&mgr,
Ok(SessionEvent::Added(sample_session(id))),
&mut sink,
&mut covered,
)
.await
.unwrap();
assert!(cont, "swallowing an event must not stop the loop");
assert!(
sink.0.is_empty(),
"an RPC-covered SessionAdded must not reach the registry; got {:?}",
decode(&sink),
);
assert!(
!covered.added.contains(&id),
"the parked id must be consumed exactly once",
);
}
#[tokio::test]
async fn rpc_covered_removed_is_swallowed_once() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let id = Uuid::new_v4();
let mut covered = RpcCovered::default();
covered.removed.insert(id);
let cont =
forward_session_event(&mgr, Ok(SessionEvent::Removed(id)), &mut sink, &mut covered)
.await
.unwrap();
assert!(cont);
assert!(
sink.0.is_empty(),
"an RPC-covered SessionRemoved must not reach the registry; got {:?}",
decode(&sink),
);
assert!(!covered.removed.contains(&id));
}
// A spontaneous change (e.g. a session created via the daemon's local HTTP
// API, or reaped on its own) is the registry's only signal, so it must still
// forward even while *other* ids are parked.
#[tokio::test]
async fn uncovered_added_still_forwards() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let parked = Uuid::new_v4();
let spontaneous = Uuid::new_v4();
let mut covered = RpcCovered::default();
covered.added.insert(parked);
let cont = forward_session_event(
&mgr,
Ok(SessionEvent::Added(sample_session(spontaneous))),
&mut sink,
&mut covered,
)
.await
.unwrap();
assert!(cont);
let frames = decode(&sink);
assert!(
matches!(
frames.as_slice(),
[DaemonOutbound::SessionAdded { session }] if session.id == spontaneous
),
"an unparked create must forward; got {frames:?}",
);
assert!(
covered.added.contains(&parked),
"an unrelated parked id must be left intact",
);
}
// A second event for the same id (e.g. the RPC was swallowed, then the
// session is reaped) is no longer covered and forwards normally.
#[tokio::test]
async fn covered_consumed_then_subsequent_event_forwards() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let id = Uuid::new_v4();
let mut covered = RpcCovered::default();
covered.added.insert(id);
// First event: the RPC-covered create — swallowed.
forward_session_event(
&mgr,
Ok(SessionEvent::Added(sample_session(id))),
&mut sink,
&mut covered,
)
.await
.unwrap();
assert!(sink.0.is_empty());
// Second event: a spontaneous removal of that same session — forwarded,
// since only the create was parked.
forward_session_event(&mgr, Ok(SessionEvent::Removed(id)), &mut sink, &mut covered)
.await
.unwrap();
let frames = decode(&sink);
assert!(
matches!(frames.as_slice(), [DaemonOutbound::SessionRemoved { id: got }] if *got == id),
"got {frames:?}",
);
}
// A lag resync is the registry's full truth, so any still-parked ids are
// stale and must be cleared (else a later unrelated event could be wrongly
// swallowed).
#[tokio::test]
async fn lag_resync_clears_parked_ids() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let mut covered = RpcCovered::default();
covered.added.insert(Uuid::new_v4());
covered.removed.insert(Uuid::new_v4());
forward_session_event(&mgr, Err(RecvError::Lagged(3)), &mut sink, &mut covered)
.await
.unwrap();
assert!(
covered.added.is_empty() && covered.removed.is_empty(),
"a full-snapshot resync must clear stale parked ids",
);
}
}
+5 -39
View File
@@ -2,7 +2,7 @@
//! worktrees, and PTY bridges.
//!
//! Owns the daemon's authoritative in-memory map of live sessions and is the
//! single point through which the registry client and the per-session WS
//! single point through which the API handlers and the per-session WS
//! handler interact with tmux.
use std::collections::{HashMap, HashSet};
@@ -23,8 +23,8 @@ use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
use crate::tmux::{Tmux, TmuxError};
use crate::worktree::{WorktreeError, WorktreeManager};
/// Capacity of the session-events broadcast channel. The registry client
/// listens; if it lags by this much we just drop frames (the registry already
/// Capacity of the session-events broadcast channel. The `/api/events`
/// publisher listens; if it lags by this much we just drop frames (clients
/// reconciles on `Hello` via the snapshot). Sourced from the shared
/// [`maestro_config::BroadcastTuning`] so all broadcast channels track one
/// definition (NAP-273).
@@ -67,8 +67,6 @@ pub enum SessionError {
Io(#[from] std::io::Error),
#[error("session not found: {0}")]
NotFound(Uuid),
#[error("request targets daemon `{wanted}`, this daemon is `{ours}`")]
WrongDaemon { wanted: String, ours: String },
}
impl SessionError {
@@ -79,7 +77,6 @@ impl SessionError {
SessionError::Sidecar(_) => "sidecar_failed",
SessionError::Io(_) => "io_failed",
SessionError::NotFound(_) => "not_found",
SessionError::WrongDaemon { .. } => "wrong_daemon",
};
ErrorInfo {
code: code.into(),
@@ -96,7 +93,6 @@ pub struct SessionManager {
#[derive(Debug)]
struct Inner {
daemon_id: String,
tmux: Tmux,
sidecar: SidecarStore,
worktree_mgr: WorktreeManager,
@@ -114,14 +110,12 @@ struct Inner {
impl SessionManager {
pub fn new(
daemon_id: impl Into<String>,
tmux: Tmux,
sidecar: SidecarStore,
worktree_mgr: WorktreeManager,
fifo_dir: PathBuf,
) -> Self {
Self::new_with_replay(
daemon_id,
tmux,
sidecar,
worktree_mgr,
@@ -133,7 +127,6 @@ impl SessionManager {
/// Variant that lets the caller override the per-session replay buffer
/// cap. Pass `0` to disable replay (subscribers see only live frames).
pub fn new_with_replay(
daemon_id: impl Into<String>,
tmux: Tmux,
sidecar: SidecarStore,
worktree_mgr: WorktreeManager,
@@ -143,7 +136,6 @@ impl SessionManager {
let (events, _) = broadcast::channel(EVENTS_CAPACITY);
Self {
inner: Arc::new(Inner {
daemon_id: daemon_id.into(),
tmux,
sidecar,
worktree_mgr,
@@ -170,10 +162,6 @@ impl SessionManager {
self
}
pub fn daemon_id(&self) -> &str {
&self.inner.daemon_id
}
/// Recent working dirs for the spawn form's picker (#24), newest-first and
/// deduped by dir. Empty when no history is wired or it's disabled.
pub fn recent_dirs(&self) -> Vec<RecentDir> {
@@ -218,13 +206,6 @@ impl SessionManager {
/// Spawn a new session. On success returns the freshly-created `Session`.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
if req.daemon_id != self.inner.daemon_id {
return Err(SessionError::WrongDaemon {
wanted: req.daemon_id,
ours: self.inner.daemon_id.clone(),
});
}
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
Some(WorktreeSpec {
base_repo, branch, ..
@@ -285,7 +266,6 @@ impl SessionManager {
let session = Session {
id,
daemon_id: self.inner.daemon_id.clone(),
cwd: effective_cwd,
command: req.command,
env: req.env,
@@ -415,11 +395,10 @@ impl SessionManager {
};
let mut adopted = 0usize;
for id in live {
let mut session = match self.inner.sidecar.read(id) {
let session = match self.inner.sidecar.read(id) {
Ok(Some(s)) => s,
_ => Session {
id,
daemon_id: self.inner.daemon_id.clone(),
cwd: PathBuf::from("/"),
command: String::new(),
env: Default::default(),
@@ -434,19 +413,6 @@ impl SessionManager {
current_cwd: None,
},
};
// The sidecar persists whatever daemon_id was current when the
// session was created. If the operator renamed the daemon between
// starts, that id is stale — re-advertise under our own id so the
// session stays routable (NAP-264).
if session.daemon_id != self.inner.daemon_id {
tracing::warn!(
%id,
sidecar_daemon_id = %session.daemon_id,
current_daemon_id = %self.inner.daemon_id,
"recovered session had a stale daemon_id; re-advertising under the current id"
);
session.daemon_id = self.inner.daemon_id.clone();
}
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
let bridge =
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
@@ -501,7 +467,7 @@ impl SessionManager {
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it
/// never stalls a tokio worker. `tmux` is a fork+exec that can take tens of
/// milliseconds on a loaded host, and `send_keys` rides the per-keystroke hot
/// path — running it inline would starve HTTP handlers, the registry WS, the
/// path — running it inline would starve HTTP handlers, the events WS, the
/// liveness watcher, and PTY readers (NAP-256).
async fn tmux_blocking<T, F>(f: F) -> T
where
+32 -75
View File
@@ -1,5 +1,5 @@
//! End-to-end tests for the per-session attach WS endpoint:
//! - bad / good JWT
//! - unknown session is rejected
//! - PTY bytes round-trip through real tmux
//! - multi-attach: two clients see the same output
@@ -9,11 +9,10 @@ use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use chrono::{Duration as ChronoDuration, Utc};
use common::DaemonFixture;
use futures_util::{SinkExt, StreamExt};
use maestro_daemon::{attach, issue_jwt, JwtVerifier, PairAttachVerifier, SessionManager};
use maestro_protocol::{CreateSessionRequest, JwtClaims};
use maestro_daemon::{local_api, LocalEventBus, SessionManager};
use maestro_protocol::CreateSessionRequest;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
@@ -21,8 +20,6 @@ use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use uuid::Uuid;
const TOKEN: &str = "shared-secret-for-tests";
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
struct Harness {
@@ -32,12 +29,16 @@ struct Harness {
_fx: DaemonFixture,
}
async fn spawn_daemon_ws(daemon_id: &str) -> Harness {
let fx = DaemonFixture::new(daemon_id);
async fn spawn_daemon_ws() -> Harness {
let fx = DaemonFixture::new();
let manager = fx.manager.clone();
let verifier = JwtVerifier::new(TOKEN, daemon_id);
let pair_verifier = PairAttachVerifier::new(TOKEN, daemon_id);
let router = attach::router(manager.clone(), verifier, pair_verifier);
let router = local_api::router(local_api::state_for(
manager.clone(),
"test-host",
"/home/test",
Vec::new(),
LocalEventBus::new(),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
@@ -58,25 +59,12 @@ async fn spawn_daemon_ws(daemon_id: &str) -> Harness {
}
}
fn token_for(session_id: Uuid, daemon_id: &str, valid_seconds: i64) -> String {
issue_jwt(
TOKEN,
&JwtClaims {
session_id,
daemon_id: daemon_id.into(),
exp: (Utc::now() + ChronoDuration::seconds(valid_seconds)).timestamp(),
iat: Some(Utc::now().timestamp()),
},
)
.unwrap()
}
#[allow(clippy::result_large_err)]
async fn connect_attach(
addr: SocketAddr,
session: Uuid,
token: &str,
) -> Result<WsClient, tokio_tungstenite::tungstenite::Error> {
let url = format!("ws://{addr}/sessions/{session}/stream?token={token}");
let url = format!("ws://{addr}/api/attach/{session}");
connect_async(url).await.map(|(ws, _)| ws)
}
@@ -86,7 +74,6 @@ async fn create_bash_session(h: &Harness) -> Uuid {
let s = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd_path,
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -100,42 +87,17 @@ async fn create_bash_session(h: &Harness) -> Uuid {
}
#[tokio::test]
async fn rejects_bad_token() {
let h = spawn_daemon_ws("laptop").await;
let id = create_bash_session(&h).await;
let err = connect_attach(h.addr, id, "not-a-jwt").await.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("401") || msg.contains("403"), "{msg}");
let _ = h.manager.kill(id).await;
}
#[tokio::test]
async fn rejects_token_for_different_session() {
let h = spawn_daemon_ws("laptop").await;
let real_id = create_bash_session(&h).await;
let other_id = Uuid::new_v4();
let token = token_for(other_id, "laptop", 300);
let err = connect_attach(h.addr, real_id, &token).await.unwrap_err();
assert!(format!("{err}").contains("403"));
let _ = h.manager.kill(real_id).await;
}
#[tokio::test]
async fn rejects_token_for_different_daemon() {
let h = spawn_daemon_ws("laptop").await;
let id = create_bash_session(&h).await;
let token = token_for(id, "dev-vm", 300);
let err = connect_attach(h.addr, id, &token).await.unwrap_err();
assert!(format!("{err}").contains("401"));
let _ = h.manager.kill(id).await;
async fn rejects_unknown_session() {
let h = spawn_daemon_ws().await;
let err = connect_attach(h.addr, Uuid::new_v4()).await.unwrap_err();
assert!(format!("{err}").contains("404"));
}
#[tokio::test]
async fn pty_round_trip_through_real_tmux() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
ws.send(WsMessage::Binary(b"echo MAESTRO_RT_OK\n".to_vec()))
.await
@@ -164,12 +126,11 @@ async fn pty_round_trip_through_real_tmux() {
#[tokio::test]
async fn multi_attach_broadcasts_to_every_client() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws_a = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws_b = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws_a = connect_attach(h.addr, id).await.unwrap();
let mut ws_b = connect_attach(h.addr, id).await.unwrap();
// give the broadcast subscribers a moment to register
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -216,10 +177,9 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() {
// Bug NAP-226: when bash exits, tmux destroys the session, the liveness
// watcher reaps it, and the broadcast channel closes — but the WS used to
// be dropped silently, leaving the client hung in raw mode.
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -251,10 +211,9 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() {
#[tokio::test]
async fn sends_close_frame_when_session_is_killed() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
// give the broadcast subscriber a moment to register before killing.
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -287,10 +246,9 @@ async fn sends_close_frame_when_session_is_killed() {
#[tokio::test]
async fn ping_text_frame_gets_pong() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
let ping = serde_json::to_string(&maestro_protocol::ClientControl::Ping).unwrap();
ws.send(WsMessage::Text(ping)).await.unwrap();
@@ -315,11 +273,10 @@ async fn ping_text_frame_gets_pong() {
async fn second_attach_sees_replay_buffer_of_prior_output() {
// NAP-229: a fresh attach must receive the buffered scrollback before
// any live frames, so reconnecting to an idle prompt isn't blank.
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let session = create_bash_session(&h).await;
let token = token_for(session, "laptop", 60);
let mut a = connect_attach(h.addr, session, &token).await.unwrap();
let mut a = connect_attach(h.addr, session).await.unwrap();
// Generate output the bash will echo back.
a.send(WsMessage::Binary(b"echo MAESTRO_REPLAY_MARK\n".to_vec()))
.await
@@ -349,7 +306,7 @@ async fn second_attach_sees_replay_buffer_of_prior_output() {
// Now attach a fresh client. It should see the prior MARK in the replay
// even though no new output has happened since.
let mut b = connect_attach(h.addr, session, &token).await.unwrap();
let mut b = connect_attach(h.addr, session).await.unwrap();
let saw_replay = tokio::time::timeout(std::time::Duration::from_secs(2), async {
let mut buf = Vec::new();
while let Some(Ok(msg)) = b.next().await {
+5 -6
View File
@@ -118,24 +118,23 @@ pub struct DaemonFixture {
}
impl DaemonFixture {
pub fn new(daemon_id: &str) -> Self {
Self::build(daemon_id, None)
pub fn new() -> Self {
Self::build(None)
}
/// Variant whose manager has a durable spawn history (#24) attached, with
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
pub fn new_with_history(daemon_id: &str, retention_days: u32) -> Self {
Self::build(daemon_id, Some(retention_days))
pub fn new_with_history(retention_days: u32) -> Self {
Self::build(Some(retention_days))
}
fn build(daemon_id: &str, history_days: Option<u32>) -> Self {
fn build(history_days: Option<u32>) -> Self {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let worktree_root = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf());
let mut manager = SessionManager::new(
daemon_id.to_string(),
tmux.tmux.clone(),
sidecar,
worktree_mgr,
+39 -181
View File
@@ -1,7 +1,4 @@
//! Integration tests for the daemon's local HTTP API + broker WS (NAP-243).
//!
//! The legacy `/sessions/:id/stream` endpoint is covered by `tests/attach.rs`;
//! this file exercises the new `/api/*` surface and the same-host broker WS.
//! Integration tests for the daemon's HTTP + WS API (NAP-243).
mod common;
@@ -11,8 +8,8 @@ use std::time::Duration;
use common::DaemonFixture;
use futures_util::{SinkExt, StreamExt};
use maestro_daemon::{local_api, BrokerHandle, CatalogMirror, LocalEventBus};
use maestro_protocol::{CreateSessionRequest, DaemonInfo, RegistryEvent, Session};
use maestro_daemon::{local_api, LocalEventBus};
use maestro_protocol::{CreateSessionRequest, Session};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -24,42 +21,24 @@ type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
struct Harness {
addr: SocketAddr,
manager: maestro_daemon::SessionManager,
catalog: CatalogMirror,
_shutdown: oneshot::Sender<()>,
_fx: DaemonFixture,
}
async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
let fx = DaemonFixture::new(daemon_id);
async fn spawn(hostname: &str) -> Harness {
let fx = DaemonFixture::new();
let manager = fx.manager.clone();
let catalog = CatalogMirror::new();
let events = LocalEventBus::new();
// NAP-247/NAP-327: tests don't exercise the cross-host broker path; hand the
// routers an isolated BrokerHandle whose receiver is dropped, so any
// resolve_attach / forward_create / forward_kill fails fast with
// NoControlLink (the handle starts disconnected).
let (broker, _broker_rx) = BrokerHandle::new();
// Built-in presets (claude/opencode/zsh + custom) so web_info advertises a
// known set (#26).
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
// a known set (#26).
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
let state = local_api::state_for(
let router = local_api::router(local_api::state_for(
manager.clone(),
hostname,
daemon_url,
"/home/test",
presets,
catalog.clone(),
events,
broker.clone(),
);
let broker_state = local_api::BrokerState {
manager: manager.clone(),
catalog: catalog.clone(),
broker,
self_daemon_id: std::sync::Arc::new(daemon_id.into()),
self_perimeter_id: None,
};
let router = local_api::router(state).merge(local_api::broker_router(broker_state));
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
@@ -75,7 +54,6 @@ async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
Harness {
addr,
manager,
catalog,
_shutdown: tx,
_fx: fx,
}
@@ -86,99 +64,22 @@ fn url(addr: SocketAddr, path: &str) -> String {
}
#[tokio::test]
async fn web_info_returns_daemon_identity() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/web/info"))
async fn info_returns_daemon_identity() {
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["daemon_id"], "laptop");
assert_eq!(resp["hostname"], "naps-laptop");
assert_eq!(resp["daemon_url"], "ws://10.0.0.1:7070");
assert_eq!(resp["home_dir"], "/home/test");
assert!(resp["version"].is_string());
}
#[tokio::test]
async fn list_daemons_includes_self_and_mirrored_peers() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
// Before any catalog events arrive, the daemon only knows about itself.
let list: Vec<DaemonInfo> = reqwest::get(url(h.addr, "/api/daemons"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].daemon_id, "laptop");
// Simulate the registry fanning out a peer addition.
h.catalog
.apply(RegistryEvent::DaemonAdded {
daemon: DaemonInfo {
daemon_id: "konishi".into(),
hostname: "konishi-host".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(),
home_dir: String::new(),
},
})
.await;
let list: Vec<DaemonInfo> = reqwest::get(url(h.addr, "/api/daemons"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(list.len(), 2);
let ids: Vec<&str> = list.iter().map(|d| d.daemon_id.as_str()).collect();
assert!(ids.contains(&"laptop"));
assert!(ids.contains(&"konishi"));
}
#[tokio::test]
async fn list_sessions_unions_own_and_peer() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
// Create one local session.
let own = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
// Inject a peer session via the mirror.
let mut peer = own.clone();
peer.id = Uuid::new_v4();
peer.daemon_id = "konishi".into();
h.catalog
.apply(RegistryEvent::SessionAdded {
session: peer.clone(),
})
.await;
let list: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
let ids: Vec<Uuid> = list.iter().map(|s| s.id).collect();
assert!(ids.contains(&own.id), "own session missing: {ids:?}");
assert!(ids.contains(&peer.id), "peer session missing: {ids:?}");
}
#[tokio::test]
async fn list_sessions_starts_empty_and_reflects_creates() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let h = spawn("naps-laptop").await;
let empty: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
@@ -189,8 +90,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
assert!(empty.is_empty());
let req = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: h.manager.daemon_id().into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
@@ -205,7 +105,6 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
.json()
.await
.unwrap();
assert_eq!(created.daemon_id, "laptop");
let after: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
@@ -218,37 +117,11 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
}
#[tokio::test]
async fn post_to_peer_daemon_id_brokers_and_503s_without_registry_link() {
// Cross-daemon create now brokers to the owner over the registry control
// link (NAP-327). This harness has no registry link (the broker starts
// disconnected), so the daemon fails fast with a retryable 503 instead of
// the old 501 — proving it attempts to broker rather than refusing.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let req = CreateSessionRequest {
daemon_id: "konishi".into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let resp = reqwest::Client::new()
.post(url(h.addr, "/api/sessions"))
.json(&req)
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "no_registry_link");
}
#[tokio::test]
async fn web_info_advertises_spawn_presets() {
// #26: the connected daemon's presets ride the web_info payload so the
// spawn form needs no extra round-trip. Built-ins + the implicit custom.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/web/info"))
async fn info_advertises_spawn_presets() {
// #26: the daemon's presets ride the info payload so the spawn form needs
// no extra round-trip. Built-ins + the implicit custom.
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
@@ -264,11 +137,11 @@ async fn web_info_advertises_spawn_presets() {
}
#[tokio::test]
async fn recent_dirs_self_returns_empty_without_history() {
// #26: the self-path reads this daemon's spawn history. The test fixture
// wires no history, so the list is empty but the endpoint is well-formed.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/daemons/laptop/recent-dirs"))
async fn recent_dirs_returns_empty_without_history() {
// #26: reads this daemon's spawn history. The test fixture wires no
// history, so the list is empty but the endpoint is well-formed.
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/recent-dirs"))
.await
.unwrap()
.json()
@@ -279,15 +152,15 @@ async fn recent_dirs_self_returns_empty_without_history() {
}
#[tokio::test]
async fn gitroot_self_probes_path() {
// #26: the self-path resolves the query against the daemon home dir and
// walks up for a `.git`. A `/tmp`-rooted temp dir is not in a repo → null.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
async fn gitroot_probes_path() {
// #26: resolves the query against the daemon home dir and walks up for a
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
let h = spawn("naps-laptop").await;
let tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url(
h.addr,
&format!(
"/api/daemons/laptop/gitroot?path={}",
"/api/gitroot?path={}",
urlencoding::encode(&tmp.path().to_string_lossy())
),
))
@@ -299,22 +172,9 @@ async fn gitroot_self_probes_path() {
assert!(resp.get("root").is_none() || resp["root"].is_null());
}
#[tokio::test]
async fn recent_dirs_for_peer_503s_without_registry_link() {
// #26: a peer target brokers over the (absent) registry link, failing fast
// with a retryable 503 — same shape as the cross-daemon create path.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp = reqwest::get(url(h.addr, "/api/daemons/konishi/recent-dirs"))
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "no_registry_link");
}
#[tokio::test]
async fn delete_unknown_session_returns_404() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
.send()
@@ -325,11 +185,10 @@ async fn delete_unknown_session_returns_404() {
#[tokio::test]
async fn delete_owned_session_kills_it() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -348,12 +207,11 @@ async fn delete_owned_session_kills_it() {
}
#[tokio::test]
async fn broker_ws_attaches_to_local_session() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
async fn attach_ws_streams_pty_bytes() {
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -367,7 +225,7 @@ async fn broker_ws_attaches_to_local_session() {
let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap();
// Provoke output. The bash process should echo this back via the PTY.
ws.send(WsMessage::Binary(b"echo hello-broker\n".to_vec()))
ws.send(WsMessage::Binary(b"echo hello-attach\n".to_vec()))
.await
.unwrap();
@@ -375,7 +233,7 @@ async fn broker_ws_attaches_to_local_session() {
loop {
match ws.next().await {
Some(Ok(WsMessage::Binary(bytes))) => {
if String::from_utf8_lossy(&bytes).contains("hello-broker") {
if String::from_utf8_lossy(&bytes).contains("hello-attach") {
return true;
}
}
@@ -386,12 +244,12 @@ async fn broker_ws_attaches_to_local_session() {
})
.await
.unwrap_or(false);
assert!(saw_hello, "PTY did not echo through the broker WS");
assert!(saw_hello, "PTY did not echo through the attach WS");
}
#[tokio::test]
async fn broker_ws_for_unknown_session_returns_404_not_502() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
async fn attach_ws_for_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
let ws_url = format!("ws://{}/api/attach/{}", h.addr, Uuid::new_v4());
let err = connect_async(ws_url)
.await
@@ -1,214 +0,0 @@
//! End-to-end tests for the daemon's outbound registry client.
//!
//! Uses the `maestro-registry` crate as the real registry under test. Verifies
//! the daemon connects, sends Hello + snapshot, forwards diffs, replies to
//! inbound CreateSession/KillSession, and reconnects after a registry kill.
mod common;
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use maestro_daemon::RegistryClient;
use maestro_protocol::CreateSessionRequest;
use maestro_registry::{router, Registry};
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
const TOKEN: &str = "shared-registry-token";
struct RegistryHarness {
url: String,
state: Registry,
shutdown: Option<oneshot::Sender<()>>,
}
async fn spawn_registry() -> RegistryHarness {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let state = Registry::new();
let app = router(state.clone(), TOKEN.to_string());
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
RegistryHarness {
url: format!("ws://{addr}/daemon"),
state,
shutdown: Some(shutdown_tx),
}
}
#[tokio::test]
async fn daemon_registers_and_streams_snapshot() {
let reg = spawn_registry().await;
let fx = DaemonFixture::new("laptop");
let client = RegistryClient::new(
reg.url.clone(),
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
tokio::spawn(async move { client.run().await });
// wait for the Hello + snapshot to land
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move {
let daemons = r.daemons().await;
daemons.len() == 1 && daemons[0].daemon_id == "laptop"
}
})
.await;
// create a session via the manager — should trigger a SessionAdded diff
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let state = reg.state.clone();
let want_id = session.id;
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move {
let s = r.sessions().await;
s.len() == 1 && s[0].id == want_id
}
})
.await;
let _ = fx.manager.kill(session.id).await;
}
#[tokio::test]
async fn daemon_reconnects_after_registry_drop() {
// first registry: accept connection, then drop
let mut reg1 = spawn_registry().await;
let url = reg1.url.clone();
let fx = DaemonFixture::new("laptop");
let client = RegistryClient::new(
url.clone(),
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
tokio::spawn(async move { client.run().await });
// wait for daemon to register on the first registry
let state1 = reg1.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state1.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
// drop registry1; daemon should reconnect once we bring another up on the
// same port. Easier: bring up a second registry on a *different* port
// requires changing url, which RegistryClient doesn't support — instead,
// just verify the daemon's task survives the drop and the original
// registry's state becomes empty (the daemon connection died).
let _ = reg1.shutdown.take().unwrap().send(());
// give some time for the daemon to notice
tokio::time::sleep(Duration::from_millis(500)).await;
// RegistryClient::run loops forever, so we can't easily assert
// reconnection without standing up the registry on the same port again.
// The hard part — graceful failure of the inner connection — is what we're
// really testing: the daemon must not panic.
// (A full reconnect-against-fresh-server test belongs in the e2e crate
// NAP-217.)
let _ = fx;
}
#[tokio::test]
async fn registry_create_session_round_trips() {
let reg = spawn_registry().await;
let fx = DaemonFixture::new("laptop");
let client = RegistryClient::new(
reg.url.clone(),
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
tokio::spawn(async move { client.run().await });
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let cwd = TempDir::new().unwrap();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let result = reg
.state
.create_session("laptop", request)
.await
.expect("transport ok");
let session = result.expect("rpc ok");
assert_eq!(session.command, "sleep 30");
// the registry should also see the SessionAdded diff in its catalog
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move { r.sessions().await.len() == 1 }
})
.await;
// and KillSession should round-trip too
let kill = reg
.state
.kill_session("laptop", session.id)
.await
.expect("transport ok");
kill.expect("rpc ok");
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move { r.sessions().await.is_empty() }
})
.await;
}
+11 -90
View File
@@ -13,13 +13,12 @@ use tempfile::TempDir;
#[tokio::test]
async fn create_then_kill_round_trip() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -55,13 +54,12 @@ async fn create_then_kill_round_trip() {
// end against a real tmux + on-disk JSONL log.
#[tokio::test]
async fn create_appends_to_spawn_history() {
let fx = DaemonFixture::new_with_history("laptop", 30);
let fx = DaemonFixture::new_with_history(30);
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -87,11 +85,10 @@ async fn create_appends_to_spawn_history() {
// recents query is empty — the spawn itself still succeeds.
#[tokio::test]
async fn create_without_history_logs_nothing() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
fx.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -104,25 +101,6 @@ async fn create_without_history_logs_nothing() {
assert!(!fx.spawn_history_path().exists());
}
#[tokio::test]
async fn rejects_wrong_daemon_in_request() {
let fx = DaemonFixture::new("laptop");
let cwd = TempDir::new().unwrap();
let err = fx
.manager
.create(CreateSessionRequest {
daemon_id: "dev-vm".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 5".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap_err();
assert!(format!("{err}").contains("targets daemon"), "{err}");
}
/// NAP-260: when the sidecar write fails *after* tmux has spawned the session,
/// `create` must roll back — no ghost tmux session, no in-memory record left
/// behind. We force the failure by making the sidecar directory read-only (the
@@ -131,7 +109,7 @@ async fn rejects_wrong_daemon_in_request() {
async fn create_rolls_back_tmux_session_when_sidecar_write_fails() {
use std::os::unix::fs::PermissionsExt;
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let sidecar_dir = fx.sidecar_dir();
@@ -154,7 +132,6 @@ async fn create_rolls_back_tmux_session_when_sidecar_write_fails() {
let res = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -183,7 +160,7 @@ async fn create_rolls_back_tmux_session_when_sidecar_write_fails() {
#[tokio::test]
async fn recover_adopts_existing_tmux_sessions() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
// create a session, then "throw away" the SessionManager (the tmux session
@@ -191,7 +168,6 @@ async fn recover_adopts_existing_tmux_sessions() {
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -206,7 +182,6 @@ async fn recover_adopts_existing_tmux_sessions() {
let worktree_mgr =
maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf());
let revived = maestro_daemon::SessionManager::new(
"laptop".to_string(),
fx.tmux.tmux.clone(),
sidecar,
worktree_mgr,
@@ -220,49 +195,6 @@ async fn recover_adopts_existing_tmux_sessions() {
assert_eq!(snap[0].command, "sleep 30");
}
/// NAP-264: if the operator renamed the daemon between starts (config edit),
/// `recover()` must re-advertise adopted sessions under the CURRENT daemon_id,
/// not the stale one baked into the sidecar — otherwise nothing routes to them.
#[tokio::test]
async fn recover_overwrites_stale_daemon_id_after_rename() {
let fx = DaemonFixture::new("laptop");
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
// Revive against the same dirs + tmux server but under a NEW daemon_id,
// as if the operator edited `daemon_id` and restarted.
let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir());
let worktree_mgr =
maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf());
let revived = maestro_daemon::SessionManager::new(
"laptop-v2".to_string(),
fx.tmux.tmux.clone(),
sidecar,
worktree_mgr,
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
let snap = revived.snapshot().await;
assert_eq!(snap[0].id, s.id);
assert_eq!(
snap[0].daemon_id, "laptop-v2",
"recovered session kept the stale daemon_id from the sidecar"
);
}
/// Regression for NAP-253: the very first bytes a freshly-spawned command
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
/// separate tmux invocation that ran after `new-session` + an async FIFO
@@ -270,13 +202,12 @@ async fn recover_overwrites_stale_daemon_id_after_rename() {
/// into the FIFO.
#[tokio::test]
async fn create_captures_initial_output_in_replay_buffer() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
// The shell prints the marker before doing anything else and
// then sleeps so the session stays alive. Before NAP-253 this
@@ -318,13 +249,12 @@ async fn create_captures_initial_output_in_replay_buffer() {
/// nothing.
#[tokio::test]
async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -350,13 +280,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(worktree_root);
let revived = maestro_daemon::SessionManager::new(
"laptop".to_string(),
tmux,
sidecar,
worktree_mgr,
fifo_dir,
);
let revived = maestro_daemon::SessionManager::new(tmux, sidecar, worktree_mgr, fifo_dir);
assert_eq!(revived.recover().await.expect("recover"), 1);
let mut rx = revived.subscribe_pty(s.id).await.expect("subscribe");
@@ -387,13 +311,12 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
#[tokio::test]
async fn pty_broadcast_delivers_bytes_to_subscribers() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -438,13 +361,12 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
#[tokio::test]
async fn liveness_watcher_reaps_dead_sessions_and_emits_removed() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -491,13 +413,12 @@ async fn liveness_watcher_reaps_dead_sessions_and_emits_removed() {
#[tokio::test]
async fn liveness_watcher_leaves_running_sessions_alone() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
+8 -9
View File
@@ -12,10 +12,9 @@ use serde_json::Value;
use tempfile::TempDir;
use uuid::Uuid;
fn sample(id: Uuid, daemon_id: &str) -> Session {
fn sample(id: Uuid) -> Session {
Session {
id,
daemon_id: daemon_id.to_string(),
cwd: PathBuf::from("/tmp/work"),
command: "bash".into(),
env: HashMap::from([("FOO".into(), "bar".into())]),
@@ -36,7 +35,7 @@ fn write_then_read_round_trip() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let s = sample(id, "laptop");
let s = sample(id);
store.write(&s).expect("write");
let loaded = store.read(id).expect("read");
@@ -59,7 +58,7 @@ fn delete_removes_file_and_is_idempotent() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let s = sample(id, "laptop");
let s = sample(id);
store.write(&s).unwrap();
assert!(store.path_for(id).exists());
@@ -76,8 +75,8 @@ fn load_all_recovers_every_sidecar_and_skips_garbage() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let s1 = sample(Uuid::new_v4(), "laptop");
let s2 = sample(Uuid::new_v4(), "dev-vm");
let s1 = sample(Uuid::new_v4());
let s2 = sample(Uuid::new_v4());
store.write(&s1).unwrap();
store.write(&s2).unwrap();
@@ -86,9 +85,9 @@ fn load_all_recovers_every_sidecar_and_skips_garbage() {
std::fs::write(dir.path().join("broken.json"), b"{not json}").unwrap();
let mut loaded = store.load_all().expect("load_all");
loaded.sort_by_key(|s| s.daemon_id.clone());
loaded.sort_by_key(|s| s.id);
let mut expected = vec![s1, s2];
expected.sort_by_key(|s| s.daemon_id.clone());
expected.sort_by_key(|s| s.id);
assert_eq!(loaded, expected);
}
@@ -104,7 +103,7 @@ fn write_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let nested = dir.path().join("a/b/c");
let store = SidecarStore::new(&nested);
let s = sample(Uuid::new_v4(), "laptop");
let s = sample(Uuid::new_v4());
store.write(&s).expect("write");
assert!(nested.exists());
assert!(store.path_for(s.id).exists());
+1 -1
View File
@@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Cross-process integration tests: real registry + daemons as subprocesses"
description = "Cross-process integration tests: a real daemon as a subprocess"
[lib]
path = "src/lib.rs"
+11 -86
View File
@@ -1,15 +1,13 @@
//! Thin HTTP+WS client used by tests to drive the registry.
//!
//! Cookies are managed automatically by `reqwest`'s built-in cookie store.
//! Thin HTTP+WS client used by tests to drive a daemon's `/api/*` surface.
#![allow(clippy::result_large_err)]
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{ConnectInfo, CreateSessionRequest, DaemonInfo, RegistryEvent, Session};
use maestro_protocol::{CatalogEvent, CreateSessionRequest, Session};
use reqwest::Client as HttpClient;
use std::sync::Arc;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
@@ -28,7 +26,7 @@ pub enum ClientError {
Status { status: u16, body: String },
}
/// Sticky-cookie HTTP client + a few WS-aware helpers.
/// HTTP client plus a few WS-aware helpers, bound to one daemon.
#[derive(Debug, Clone)]
pub struct TestClient {
http_base: String,
@@ -39,7 +37,6 @@ pub struct TestClient {
impl TestClient {
pub fn new(http_base: &str, ws_base: &str) -> Self {
let http = HttpClient::builder()
.cookie_store(true)
.timeout(Duration::from_secs(5))
.build()
.expect("reqwest client");
@@ -50,23 +47,6 @@ impl TestClient {
}
}
/// Hit any GET endpoint so the registry issues the auth cookie that all
/// subsequent calls reuse.
pub async fn bootstrap(&self) -> Result<(), ClientError> {
let _ = self.list_daemons().await?;
Ok(())
}
pub async fn list_daemons(&self) -> Result<Vec<DaemonInfo>, ClientError> {
let r = self
.http
.get(format!("{}/api/daemons", self.http_base))
.send()
.await?;
check_ok(&r)?;
Ok(r.json().await?)
}
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
let r = self
.http
@@ -105,50 +85,23 @@ impl TestClient {
Ok(())
}
pub async fn connect_info(&self, id: Uuid) -> Result<ConnectInfo, ClientError> {
let r = self
.http
.get(format!("{}/api/sessions/{id}/connect-info", self.http_base))
.send()
.await?;
check_ok(&r)?;
Ok(r.json().await?)
}
/// Connect to the daemon's per-session WS and return the stream. The
/// caller drives the conversation.
pub async fn attach(
&self,
info: &ConnectInfo,
id: Uuid,
) -> Result<WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>, ClientError> {
let url = format!(
"{}/sessions/{id}/stream?token={}",
info.daemon_url.trim_end_matches('/'),
info.jwt
);
let url = format!("{}/api/attach/{id}", self.ws_base);
let request = url.into_client_request()?;
let (ws, _resp) = connect_async(request).await?;
Ok(ws)
}
/// Subscribe to the registry's `/api/events` WS. Returns a tokio task
/// handle that drives the WS and pushes parsed events into an Arc<Mutex<Vec>>.
/// Subscribe to the daemon's `/api/events` WS. Returns a handle whose
/// background task pushes parsed events into a shared vector.
pub async fn subscribe_events(&self) -> EventStream {
// Forward the latest cookie value with the upgrade — `reqwest` and
// `tungstenite` don't share cookie jars, so we copy the cookie out by
// hand. The registry will accept us either way (missing cookie just
// gets a fresh one), but reusing the same identity keeps log noise
// down.
let cookie_header = self.grab_cookie().await.unwrap_or_default();
let url = format!("{}/api/events", self.ws_base);
let mut request = url.into_client_request().expect("ws req");
if !cookie_header.is_empty() {
request.headers_mut().insert(
reqwest::header::COOKIE,
cookie_header.parse().expect("cookie hv"),
);
}
let request = url.into_client_request().expect("ws req");
let (ws, _resp) = connect_async(request).await.expect("events ws");
let events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
@@ -158,7 +111,7 @@ impl TestClient {
while let Some(msg) = stream.next().await {
match msg {
Ok(WsMessage::Text(t)) => {
if let Ok(ev) = serde_json::from_str::<RegistryEvent>(&t) {
if let Ok(ev) = serde_json::from_str::<CatalogEvent>(&t) {
stream_events.lock().await.push(ev);
}
}
@@ -170,34 +123,6 @@ impl TestClient {
});
EventStream { events, task }
}
async fn grab_cookie(&self) -> Option<String> {
// Force a request to make sure the jar has a cookie, then read it back
// via a debug header roundtrip. reqwest's CookieStore isn't directly
// exposed; the simplest path is to ask the registry for an auth
// cookie and capture the Set-Cookie from the response.
let r = self
.http
.get(format!("{}/api/daemons", self.http_base))
.send()
.await
.ok()?;
// The cookie store has the cookie now, but reqwest doesn't surface
// it. We rebuild from Set-Cookie if present.
r.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.find_map(|hv| hv.to_str().ok().and_then(extract_cookie_pair))
}
}
fn extract_cookie_pair(set_cookie: &str) -> Option<String> {
// Take everything before the first ';', which is `name=value`.
set_cookie
.split(';')
.next()
.map(str::trim)
.map(String::from)
}
fn check_ok(r: &reqwest::Response) -> Result<(), ClientError> {
@@ -214,12 +139,12 @@ fn check_ok(r: &reqwest::Response) -> Result<(), ClientError> {
/// Handle to a background task draining the events WS into a vector.
#[derive(Debug)]
pub struct EventStream {
events: Arc<tokio::sync::Mutex<Vec<RegistryEvent>>>,
events: Arc<tokio::sync::Mutex<Vec<CatalogEvent>>>,
task: tokio::task::JoinHandle<()>,
}
impl EventStream {
pub async fn snapshot(&self) -> Vec<RegistryEvent> {
pub async fn snapshot(&self) -> Vec<CatalogEvent> {
self.events.lock().await.clone()
}
}
+39 -131
View File
@@ -1,4 +1,4 @@
//! Subprocess wrapper around `maestrod`.
//! Subprocess wrapper around `maestro daemon`.
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
@@ -10,68 +10,49 @@ use tempfile::TempDir;
use crate::build::workspace_target_bin;
use crate::util::{free_port, wait_for_async};
/// A live `maestrod` running in a child process. Kill the child on drop.
/// A live daemon running in a child process. Kill the child on drop.
///
/// `state_dir`, `home`, and `tmux_tmpdir` are kept as `TempDir`s so they
/// outlive the process and get cleaned up automatically.
/// `home` and `tmux_tmpdir` are kept as `TempDir`s so they outlive the process
/// and get cleaned up automatically. State lives under `home` via
/// `XDG_STATE_HOME`.
#[derive(Debug)]
pub struct Daemon {
pub daemon_id: String,
pub bind_addr: SocketAddr,
pub daemon_url: String,
child: Option<Child>,
home: TempDir,
state: TempDir,
tmux: TempDir,
registry_url: String,
token: String,
}
/// Optional knobs for `Daemon::spawn_with` — perimeter advertisement for
/// NAP-247 broker tests.
#[derive(Debug, Default, Clone)]
pub struct DaemonOptions {
pub perimeter_id: Option<String>,
pub private_addr: Option<String>,
}
impl Daemon {
/// Spawn a fresh daemon pointing at `registry_base_url` (the registry's
/// HTTP base, e.g. `http://127.0.0.1:1234`). The daemon derives its WS
/// endpoint internally via `derive_daemon_ws_url`.
pub async fn spawn(daemon_id: &str, registry_base_url: &str, token: &str) -> Self {
Self::spawn_with(
daemon_id,
registry_base_url,
token,
DaemonOptions::default(),
)
.await
/// Spawn a fresh daemon on a free localhost port.
pub async fn spawn() -> Self {
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
}
/// Spawn with NAP-247 options (perimeter advertisement).
pub async fn spawn_with(
daemon_id: &str,
registry_base_url: &str,
token: &str,
opts: DaemonOptions,
) -> Self {
let port = free_port();
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
Self::spawn_with_port_and_opts(daemon_id, registry_base_url, token, bind, opts).await
/// Spawn on a caller-supplied bind address.
pub async fn spawn_at(bind: SocketAddr) -> Self {
let home = TempDir::new().expect("home tmpdir");
let tmux = TempDir::new().expect("tmux tmpdir");
let child = launch(bind, home.path(), tmux.path());
let me = Self {
bind_addr: bind,
child: Some(child),
home,
tmux,
};
me.wait_until_ready().await;
me
}
/// Spawn on a caller-supplied bind address. Used by NAP-247 broker tests
/// where the daemon's advertised `private_addr` must equal the actual
/// bind address so the broker can dial it back.
pub async fn spawn_at(
daemon_id: &str,
registry_base_url: &str,
token: &str,
bind: SocketAddr,
opts: DaemonOptions,
) -> Self {
Self::spawn_with_port_and_opts(daemon_id, registry_base_url, token, bind, opts).await
/// HTTP base URL of this daemon's API.
pub fn http_base(&self) -> String {
format!("http://{}", self.bind_addr)
}
/// WS base URL of this daemon's API.
pub fn ws_base(&self) -> String {
format!("ws://{}", self.bind_addr)
}
/// Stop the underlying process, but keep its state dirs around so we can
@@ -85,70 +66,18 @@ impl Daemon {
/// Re-spawn the daemon on the same port + state. The previous tmux
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
/// its sessions. Restarts inherit the original options via the on-disk
/// config file written at `spawn_with_port_and_opts` time.
/// its sessions.
pub async fn restart(&mut self) {
self.stop();
let child = launch(
&self.daemon_id,
self.bind_addr,
&self.daemon_url,
&self.registry_url,
&self.token,
self.home.path(),
self.state.path(),
self.tmux.path(),
&DaemonOptions::default(),
);
self.child = Some(child);
self.wait_until_attach_ready().await;
self.child = Some(launch(self.bind_addr, self.home.path(), self.tmux.path()));
self.wait_until_ready().await;
}
async fn spawn_with_port_and_opts(
daemon_id: &str,
registry_url: &str,
token: &str,
bind: SocketAddr,
opts: DaemonOptions,
) -> Self {
let home = TempDir::new().expect("home tmpdir");
let state = TempDir::new().expect("state tmpdir");
let tmux = TempDir::new().expect("tmux tmpdir");
let daemon_url = format!("ws://{bind}");
let child = launch(
daemon_id,
bind,
&daemon_url,
registry_url,
token,
home.path(),
state.path(),
tmux.path(),
&opts,
);
let me = Self {
daemon_id: daemon_id.to_string(),
bind_addr: bind,
daemon_url,
child: Some(child),
home,
state,
tmux,
registry_url: registry_url.to_string(),
token: token.to_string(),
};
me.wait_until_attach_ready().await;
me
}
async fn wait_until_attach_ready(&self) {
async fn wait_until_ready(&self) {
let addr = self.bind_addr;
wait_for_async(
"daemon attach port",
Duration::from_secs(5),
|| async move { tokio::net::TcpStream::connect(addr).await.is_ok() },
)
wait_for_async("daemon api port", Duration::from_secs(5), || async move {
tokio::net::TcpStream::connect(addr).await.is_ok()
})
.await;
}
@@ -158,7 +87,7 @@ impl Daemon {
pub fn state_dir(&self) -> PathBuf {
// Matches maestro_daemon::default_state_dir which uses XDG_STATE_HOME.
self.home.path().join(".local/state/maestrod")
self.home.path().join(".local/state/maestro")
}
}
@@ -171,18 +100,7 @@ impl Drop for Daemon {
}
}
#[allow(clippy::too_many_arguments)]
fn launch(
daemon_id: &str,
bind: SocketAddr,
daemon_url: &str,
registry_url: &str,
token: &str,
home: &std::path::Path,
_state: &std::path::Path,
tmux_tmpdir: &std::path::Path,
opts: &DaemonOptions,
) -> Child {
fn launch(bind: SocketAddr, home: &std::path::Path, tmux_tmpdir: &std::path::Path) -> Child {
let bin = workspace_target_bin("maestro", "maestro");
let mut cmd = Command::new(&bin);
cmd.arg("daemon");
@@ -195,21 +113,11 @@ fn launch(
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("XDG_STATE_HOME", home.join(".local/state"))
.env("TMUX_TMPDIR", tmux_tmpdir)
.env("MAESTRO_TOKEN", token)
.env("MAESTRO_DAEMON_ID", daemon_id)
.env("MAESTRO_REGISTRY_URL", registry_url)
.env("MAESTRO_DAEMON_BIND_ADDR", bind.to_string())
.env("MAESTRO_DAEMON_URL", daemon_url)
.env("RUST_LOG", "warn")
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.arg("run");
if let Some(p) = &opts.perimeter_id {
cmd.env("MAESTRO_DAEMON_PERIMETER_ID", p);
}
if let Some(a) = &opts.private_addr {
cmd.env("MAESTRO_DAEMON_PRIVATE_ADDR", a);
}
cmd.spawn()
.unwrap_or_else(|e| panic!("spawn {} failed: {e}", bin.display()))
}
+4 -6
View File
@@ -1,8 +1,8 @@
//! Cross-process integration test harness for Maestro.
//!
//! Each test spawns a real `maestro-registry` and one or more `maestrod`
//! subprocesses, isolates them in temp dirs (`$HOME`, `TMUX_TMPDIR`, state
//! dir), and drives them through their public HTTP/WS APIs.
//! Each test spawns a real `maestro daemon` subprocess, isolates it in temp
//! dirs (`$HOME`, `TMUX_TMPDIR`, state dir), and drives it through its public
//! HTTP/WS API.
//!
//! See `tests/` for the actual scenarios.
@@ -11,10 +11,8 @@
pub mod build;
pub mod client;
pub mod daemon;
pub mod registry;
pub mod util;
pub use build::workspace_target_bin;
pub use client::TestClient;
pub use daemon::{Daemon, DaemonOptions};
pub use registry::Registry;
pub use daemon::Daemon;
-95
View File
@@ -1,95 +0,0 @@
//! Subprocess wrapper around `maestro-registry`.
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use tempfile::TempDir;
use crate::build::workspace_target_bin;
use crate::util::{free_port, wait_for_async};
/// A `maestro-registry` running in a child process. The state directory is
/// kept as a `TempDir` so it survives the `Registry`'s lifetime and gets
/// cleaned up on drop.
#[derive(Debug)]
pub struct Registry {
pub addr: SocketAddr,
pub token: String,
child: Option<Child>,
_home: TempDir,
}
impl Registry {
pub async fn spawn(token: &str) -> Self {
let port = free_port();
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let home = TempDir::new().expect("home tmpdir");
let bin = workspace_target_bin("maestro", "maestro");
let child = Command::new(&bin)
.arg("registry")
.env_clear()
// PATH so tmux etc. resolve. We need a few essentials.
.env(
"PATH",
std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into()),
)
.env("HOME", home.path())
.env("XDG_CONFIG_HOME", home.path().join(".config"))
.env("XDG_STATE_HOME", home.path().join(".local/state"))
.env("MAESTRO_TOKEN", token)
.env("MAESTRO_REGISTRY_BIND_ADDR", addr.to_string())
.env("RUST_LOG", "warn")
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.unwrap_or_else(|e| panic!("spawn {} failed: {e}", bin.display()));
let url = format!("http://{addr}/api/daemons");
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("reqwest client");
wait_for_async("registry to bind", Duration::from_secs(5), || {
let http = http.clone();
let url = url.clone();
async move { http.get(&url).send().await.is_ok() }
})
.await;
Self {
addr,
token: token.to_string(),
child: Some(child),
_home: home,
}
}
pub fn http_base(&self) -> String {
format!("http://{}", self.addr)
}
pub fn ws_base(&self) -> String {
format!("ws://{}", self.addr)
}
/// URL daemons dial.
pub fn daemon_ws_url(&self) -> String {
format!("ws://{}/daemon", self.addr)
}
pub fn state_dir(&self) -> PathBuf {
self._home.path().to_path_buf()
}
}
impl Drop for Registry {
fn drop(&mut self) {
if let Some(mut c) = self.child.take() {
let _ = c.kill();
let _ = c.wait();
}
}
}
+58 -554
View File
@@ -1,9 +1,8 @@
//! Cross-process end-to-end tests for the headless Maestro slice.
//!
//! Each test brings up a real `maestro-registry` plus 1-2 real `maestrod`
//! subprocesses, isolates them in temp dirs, and drives them through the
//! public HTTP+WS surface using the same client APIs a real CLI/web client
//! would.
//! Each test brings up a real daemon subprocess, isolated in temp dirs, and
//! drives it through the public HTTP+WS surface using the same client APIs a
//! real CLI/web client would.
//!
//! These tests require `tmux` and `git` on `$PATH`.
@@ -15,45 +14,19 @@ use std::time::{Duration, Instant};
use futures_util::{SinkExt, StreamExt};
use maestro_e2e::util::wait_for_async;
use maestro_e2e::{Daemon, DaemonOptions, Registry, TestClient};
use maestro_e2e::{Daemon, TestClient};
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
use tempfile::TempDir;
use tokio_tungstenite::tungstenite::Message as WsMessage;
const TOKEN: &str = "e2e-shared-token";
async fn bring_up(daemon_ids: &[&str]) -> (Registry, Vec<Daemon>, TestClient) {
let registry = Registry::spawn(TOKEN).await;
let mut daemons = Vec::with_capacity(daemon_ids.len());
for id in daemon_ids {
let d = Daemon::spawn(id, &registry.http_base(), TOKEN).await;
daemons.push(d);
}
let client = TestClient::new(&registry.http_base(), &registry.ws_base());
client.bootstrap().await.expect("bootstrap");
// Wait until both daemons have connected and announced themselves.
let expected: Vec<String> = daemon_ids.iter().map(|s| s.to_string()).collect();
wait_for_async("daemons to register", Duration::from_secs(10), || {
let client = client.clone();
let expected = expected.clone();
async move {
match client.list_daemons().await {
Ok(ds) => expected
.iter()
.all(|id| ds.iter().any(|d| &d.daemon_id == id)),
Err(_) => false,
}
}
})
.await;
(registry, daemons, client)
async fn bring_up() -> (Daemon, TestClient) {
let daemon = Daemon::spawn().await;
let client = TestClient::new(&daemon.http_base(), &daemon.ws_base());
(daemon, client)
}
fn create_req(daemon_id: &str, cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
daemon_id: daemon_id.into(),
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
@@ -63,211 +36,39 @@ fn create_req(daemon_id: &str, cwd: &std::path::Path, command: &str) -> CreateSe
}
#[tokio::test(flavor = "multi_thread")]
async fn lists_sessions_from_both_daemons() {
let (_reg, _daemons, client) = bring_up(&["alpha", "beta"]).await;
async fn lists_spawned_sessions() {
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let a = client
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
.spawn_session(&create_req(cwd.path(), "sleep 30"))
.await
.expect("spawn alpha");
.expect("spawn a");
let b = client
.spawn_session(&create_req("beta", cwd.path(), "sleep 30"))
.spawn_session(&create_req(cwd.path(), "sleep 30"))
.await
.expect("spawn beta");
.expect("spawn b");
let sessions = client.list_sessions().await.expect("list");
assert!(sessions
.iter()
.any(|s| s.id == a.id && s.daemon_id == "alpha"));
assert!(sessions
.iter()
.any(|s| s.id == b.id && s.daemon_id == "beta"));
assert!(sessions.iter().any(|s| s.id == a.id));
assert!(sessions.iter().any(|s| s.id == b.id));
client.kill_session(a.id).await.expect("kill a");
client.kill_session(b.id).await.expect("kill b");
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_local_api_mirrors_peer_catalog() {
// NAP-246: a daemon's GET /api/daemons + /api/sessions should reflect
// peer state (not just self), because the registry fans out catalog
// events to every connected daemon.
let (_reg, daemons, client) = bring_up(&["alpha", "beta"]).await;
let cwd = TempDir::new().unwrap();
// Create a session on beta via the registry path.
let b = client
.spawn_session(&create_req("beta", cwd.path(), "sleep 30"))
.await
.expect("spawn beta");
// Now query alpha's local HTTP API and confirm it sees beta + beta's
// session. The mirror is eventually consistent, so poll.
let alpha_addr = daemons[0].bind_addr;
let alpha_base = format!("http://{alpha_addr}");
wait_for_async(
"alpha mirrors beta in /api/daemons",
Duration::from_secs(5),
|| {
let base = alpha_base.clone();
async move {
let resp = match reqwest::get(format!("{base}/api/daemons")).await {
Ok(r) => r,
Err(_) => return false,
};
let list: Vec<maestro_protocol::DaemonInfo> = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
};
list.iter().any(|d| d.daemon_id == "beta")
}
},
)
.await;
wait_for_async(
"alpha mirrors beta's session in /api/sessions",
Duration::from_secs(5),
|| {
let base = alpha_base.clone();
let want = b.id;
async move {
let resp = match reqwest::get(format!("{base}/api/sessions")).await {
Ok(r) => r,
Err(_) => return false,
};
let list: Vec<maestro_protocol::Session> = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
};
list.iter().any(|s| s.id == want && s.daemon_id == "beta")
}
},
)
.await;
// Both self and peer come back.
let list: Vec<maestro_protocol::DaemonInfo> = reqwest::get(format!("{alpha_base}/api/daemons"))
.await
.unwrap()
.json()
.await
.unwrap();
let ids: Vec<&str> = list.iter().map(|d| d.daemon_id.as_str()).collect();
assert!(ids.contains(&"alpha"));
assert!(ids.contains(&"beta"));
// NAP-328: home_dir is advertised end-to-end — for a peer relayed through
// the registry (beta, via Hello → register → catalog mirror), so the web
// spawn form can prefill the working directory.
let beta = list.iter().find(|d| d.daemon_id == "beta").unwrap();
assert!(
!beta.home_dir.is_empty(),
"peer home_dir should propagate through the registry: {beta:?}"
);
client.kill_session(b.id).await.expect("kill b");
}
#[tokio::test(flavor = "multi_thread")]
async fn home_daemon_brokers_cross_daemon_create_and_kill() {
// NAP-327: POST /api/sessions to alpha (home) with daemon_id=beta must
// broker the create to beta over the registry control link and return
// beta's session — and a kill via alpha must reach beta too. This is the
// path maestro-web uses (it only talks to its local daemon).
let (_reg, daemons, registry_client) = bring_up(&["alpha", "beta"]).await;
let cwd = TempDir::new().unwrap();
// A client pointed at alpha's *local* API (not the registry).
let alpha_base = format!("http://{}", daemons[0].bind_addr);
let alpha = TestClient::new(&alpha_base, &alpha_base);
// Create a beta-owned session by asking alpha.
let s = alpha
.spawn_session(&create_req("beta", cwd.path(), "sleep 30"))
.await
.expect("alpha brokers create to beta");
assert_eq!(s.daemon_id, "beta", "session should be owned by beta");
// The registry catalog (and thus every client) sees it on beta.
wait_for_async(
"beta session visible in catalog",
Duration::from_secs(5),
|| {
let client = registry_client.clone();
let want = s.id;
async move {
client
.list_sessions()
.await
.map(|ss| ss.iter().any(|x| x.id == want && x.daemon_id == "beta"))
.unwrap_or(false)
}
},
)
.await;
// alpha's mirror must reflect beta's session before it can broker the kill.
wait_for_async(
"alpha mirrors beta's session",
Duration::from_secs(5),
|| {
let base = alpha_base.clone();
let want = s.id;
async move {
let resp = match reqwest::get(format!("{base}/api/sessions")).await {
Ok(r) => r,
Err(_) => return false,
};
let list: Vec<maestro_protocol::Session> = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
};
list.iter().any(|x| x.id == want)
}
},
)
.await;
// Kill the beta session via alpha — brokered to beta.
alpha
.kill_session(s.id)
.await
.expect("alpha brokers kill to beta");
wait_for_async(
"beta session gone from catalog",
Duration::from_secs(5),
|| {
let client = registry_client.clone();
let gone = s.id;
async move {
client
.list_sessions()
.await
.map(|ss| !ss.iter().any(|x| x.id == gone))
.unwrap_or(false)
}
},
)
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn multi_attach_sees_same_output() {
let (_reg, _daemons, client) = bring_up(&["solo"]).await;
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let s = client
.spawn_session(&create_req("solo", cwd.path(), "bash --noprofile --norc"))
.spawn_session(&create_req(cwd.path(), "bash --noprofile --norc"))
.await
.expect("spawn");
let info = client.connect_info(s.id).await.expect("connect-info");
let mut a = client.attach(&info, s.id).await.expect("attach a");
let mut b = client.attach(&info, s.id).await.expect("attach b");
let mut a = client.attach(s.id).await.expect("attach a");
let mut b = client.attach(s.id).await.expect("attach b");
// Drive input through client A; both attachments should observe it via
// the daemon's PTY broadcast.
@@ -314,13 +115,12 @@ async fn multi_attach_sees_same_output() {
#[tokio::test(flavor = "multi_thread")]
async fn cleanup_on_kill_removes_worktree() {
let (_reg, _daemons, client) = bring_up(&["host"]).await;
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let req = CreateSessionRequest {
daemon_id: "host".into(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -362,52 +162,20 @@ async fn cleanup_on_kill_removes_worktree() {
#[tokio::test(flavor = "multi_thread")]
async fn daemon_restart_recovers_session() {
let (_reg, mut daemons, client) = bring_up(&["restarter"]).await;
let (mut daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let s = client
.spawn_session(&create_req("restarter", cwd.path(), "sleep 300"))
.spawn_session(&create_req(cwd.path(), "sleep 300"))
.await
.expect("spawn");
assert_eq!(s.daemon_id, "restarter");
// Stop the daemon and wait for the registry to notice the disconnect.
// The catalog drops sessions for a daemon that's no longer connected.
daemons[0].stop();
wait_for_async(
"registry drops disconnected daemon",
Duration::from_secs(10),
|| {
let client = client.clone();
async move {
client
.list_daemons()
.await
.map(|ds| ds.iter().all(|d| d.daemon_id != "restarter"))
.unwrap_or(false)
}
},
)
.await;
// Bring the daemon back. It should recover its tmux session and the
// registry catalog should repopulate it.
daemons[0].restart().await;
wait_for_async("restarter re-registers", Duration::from_secs(10), || {
let client = client.clone();
async move {
client
.list_daemons()
.await
.map(|ds| ds.iter().any(|d| d.daemon_id == "restarter"))
.unwrap_or(false)
}
})
.await;
// Restart against the same tmux server + state dir: the daemon must adopt
// the still-running tmux session on boot.
daemon.restart().await;
wait_for_async(
"session reappears in catalog",
"session reappears after restart",
Duration::from_secs(10),
|| {
let client = client.clone();
@@ -428,7 +196,7 @@ async fn daemon_restart_recovers_session() {
#[tokio::test(flavor = "multi_thread")]
async fn concurrent_spawns_each_get_unique_ids() {
let (_reg, _daemons, client) = bring_up(&["parallel"]).await;
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let mut handles = Vec::new();
@@ -437,7 +205,7 @@ async fn concurrent_spawns_each_get_unique_ids() {
let path = cwd.path().to_path_buf();
handles.push(tokio::spawn(async move {
client
.spawn_session(&create_req("parallel", &path, "sleep 30"))
.spawn_session(&create_req(&path, "sleep 30"))
.await
.expect("spawn")
}));
@@ -461,8 +229,8 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
// the daemon's recent-dirs endpoint surfaces on the spawn form's next open
// (#27's recents picker), and gitroot infers the worktree base_repo from the
// same dir. Exercised end to end against real tmux + git.
let (_reg, daemons, client) = bring_up(&["host"]).await;
let daemon_base = format!("http://{}", daemons[0].bind_addr);
let (daemon, client) = bring_up().await;
let daemon_base = daemon.http_base();
// A git repo as the working dir, so gitroot has a repo to infer.
let repo = TempDir::new().unwrap();
@@ -471,7 +239,7 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
// Spawn in that dir (the command stands in for a preset tile selection).
let s = client
.spawn_session(&create_req("host", repo.path(), "sleep 30"))
.spawn_session(&create_req(repo.path(), "sleep 30"))
.await
.expect("spawn");
@@ -482,7 +250,7 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
"recent-dirs surfaces the spawned cwd",
Duration::from_secs(5),
|| {
let url = format!("{daemon_base}/api/daemons/host/recent-dirs");
let url = format!("{daemon_base}/api/recent-dirs");
let want = cwd.clone();
async move {
let resp = match reqwest::get(&url).await {
@@ -505,7 +273,7 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
// gitroot infers the worktree base_repo from the same dir (#25/#26): the dir
// is itself the repo toplevel (it holds `.git`).
let gr: maestro_protocol::GitRoot = reqwest::Client::new()
.get(format!("{daemon_base}/api/daemons/host/gitroot"))
.get(format!("{daemon_base}/api/gitroot"))
.query(&[("path", cwd.as_str())])
.send()
.await
@@ -530,15 +298,15 @@ async fn activity_reflects_foreground_and_input_blocking() {
// (the distinction the /proc/<pid>/syscall check buys us). Both report the
// right foreground. Exercised against real tmux.
use maestro_protocol::ActivityState;
let (_reg, _daemons, client) = bring_up(&["host"]).await;
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let cat = client
.spawn_session(&create_req("host", cwd.path(), "cat"))
.spawn_session(&create_req(cwd.path(), "cat"))
.await
.expect("spawn cat");
let slp = client
.spawn_session(&create_req("host", cwd.path(), "sleep 300"))
.spawn_session(&create_req(cwd.path(), "sleep 300"))
.await
.expect("spawn sleep");
@@ -621,244 +389,14 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn broker_t2_attach_through_home_daemon() {
// NAP-247: two daemons share perimeter "wg-test". alpha's broker resolves
// a session owned by beta, gets a pair JWT + perimeter address from the
// registry, dials beta via T2, and proxies the attach.
let registry = Registry::spawn("e2e-shared-token").await;
let beta_bind = std::net::SocketAddr::from((
std::net::Ipv4Addr::LOCALHOST,
maestro_e2e::util::free_port(),
));
let beta = Daemon::spawn_at(
"beta",
&registry.http_base(),
"e2e-shared-token",
beta_bind,
DaemonOptions {
perimeter_id: Some("wg-test".into()),
private_addr: Some(beta_bind.to_string()),
},
)
.await;
let alpha_bind = std::net::SocketAddr::from((
std::net::Ipv4Addr::LOCALHOST,
maestro_e2e::util::free_port(),
));
let alpha = Daemon::spawn_at(
"alpha",
&registry.http_base(),
"e2e-shared-token",
alpha_bind,
DaemonOptions {
perimeter_id: Some("wg-test".into()),
private_addr: Some(alpha_bind.to_string()),
},
)
.await;
assert_eq!(beta.bind_addr, beta_bind);
assert_eq!(alpha.bind_addr, alpha_bind);
let client = TestClient::new(&registry.http_base(), &registry.ws_base());
client.bootstrap().await.expect("bootstrap");
wait_for_async("both daemons connected", Duration::from_secs(10), || {
let client = client.clone();
async move {
client
.list_daemons()
.await
.map(|ds| {
ds.iter().any(|d| d.daemon_id == "alpha")
&& ds.iter().any(|d| d.daemon_id == "beta")
})
.unwrap_or(false)
}
})
.await;
let cwd = TempDir::new().unwrap();
let session = client
.spawn_session(&create_req("beta", cwd.path(), "bash --noprofile --norc"))
.await
.expect("spawn beta");
// Wait until alpha's catalog mirror sees the peer session — otherwise the
// broker can't locate the owning daemon.
let alpha_base = format!("http://{}", alpha.bind_addr);
let sid = session.id;
wait_for_async(
"alpha mirrors beta's session",
Duration::from_secs(5),
|| {
let base = alpha_base.clone();
async move {
let resp = match reqwest::get(format!("{base}/api/sessions")).await {
Ok(r) => r,
Err(_) => return false,
};
let list: Vec<maestro_protocol::Session> = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
};
list.iter().any(|s| s.id == sid && s.daemon_id == "beta")
}
},
)
.await;
// Attach through alpha's broker.
let broker_url = format!("ws://{}/api/attach/{}", alpha.bind_addr, sid);
let (mut ws, _) = tokio_tungstenite::connect_async(broker_url)
.await
.expect("broker WS handshake");
ws.send(WsMessage::Binary(b"echo MAESTRO_BROKER_E2E\n".to_vec()))
.await
.expect("send keys");
let want = b"MAESTRO_BROKER_E2E";
let mut buf = Vec::new();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
tokio::select! {
msg = ws.next() => match msg {
Some(Ok(WsMessage::Binary(b))) => buf.extend(b),
Some(Ok(_)) => {}
_ => break,
},
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
}
if buf.windows(want.len()).any(|w| w == want) {
break;
}
}
assert!(
buf.windows(want.len()).any(|w| w == want),
"broker WS never echoed our keystrokes: buf len {}",
buf.len()
);
let _ = ws.close(None).await;
client.kill_session(sid).await.expect("kill");
drop(alpha);
drop(beta);
drop(registry);
}
#[tokio::test(flavor = "multi_thread")]
async fn broker_attach_returns_502_on_perimeter_mismatch() {
// NAP-270: the asymmetric-VLAN boundary. alpha and beta are on DIFFERENT
// perimeters, so alpha's broker must refuse T2 (the only implemented
// transport) with a structured 502 rather than dial across perimeters.
let registry = Registry::spawn("e2e-shared-token").await;
let beta_bind = std::net::SocketAddr::from((
std::net::Ipv4Addr::LOCALHOST,
maestro_e2e::util::free_port(),
));
let beta = Daemon::spawn_at(
"beta",
&registry.http_base(),
"e2e-shared-token",
beta_bind,
DaemonOptions {
perimeter_id: Some("wg-beta".into()),
private_addr: Some(beta_bind.to_string()),
},
)
.await;
let alpha_bind = std::net::SocketAddr::from((
std::net::Ipv4Addr::LOCALHOST,
maestro_e2e::util::free_port(),
));
let alpha = Daemon::spawn_at(
"alpha",
&registry.http_base(),
"e2e-shared-token",
alpha_bind,
DaemonOptions {
// Different perimeter from beta → T2 unavailable.
perimeter_id: Some("wg-alpha".into()),
private_addr: Some(alpha_bind.to_string()),
},
)
.await;
let client = TestClient::new(&registry.http_base(), &registry.ws_base());
client.bootstrap().await.expect("bootstrap");
wait_for_async("both daemons connected", Duration::from_secs(10), || {
let client = client.clone();
async move {
client
.list_daemons()
.await
.map(|ds| ds.len() >= 2)
.unwrap_or(false)
}
})
.await;
let cwd = TempDir::new().unwrap();
let session = client
.spawn_session(&create_req("beta", cwd.path(), "sleep 30"))
.await
.expect("spawn beta");
let sid = session.id;
// Wait until alpha's catalog mirror sees beta's session.
let alpha_base = format!("http://{}", alpha.bind_addr);
wait_for_async(
"alpha mirrors beta's session",
Duration::from_secs(5),
|| {
let base = alpha_base.clone();
async move {
let resp = match reqwest::get(format!("{base}/api/sessions")).await {
Ok(r) => r,
Err(_) => return false,
};
let list: Vec<maestro_protocol::Session> = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
};
list.iter().any(|s| s.id == sid)
}
},
)
.await;
// Attach through alpha's broker — must 502 (perimeter mismatch), never
// upgrade and never dial beta.
let broker_url = format!("ws://{}/api/attach/{}", alpha.bind_addr, sid);
let err = tokio_tungstenite::connect_async(broker_url)
.await
.expect_err("cross-perimeter attach must fail the handshake");
match err {
tokio_tungstenite::tungstenite::Error::Http(resp) => {
assert_eq!(
resp.status(),
502,
"expected BAD_GATEWAY for perimeter mismatch, got {}",
resp.status()
);
}
other => panic!("expected an HTTP 502 response, got {other:?}"),
}
client.kill_session(sid).await.expect("kill");
drop(alpha);
drop(beta);
drop(registry);
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_events_ws_streams_snapshot_then_session_added() {
// NAP-248: subscribe to a daemon's WS /api/events, expect an initial
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
// Snapshot, then a SessionAdded after we spawn a session via REST.
let (_reg, daemons, client) = bring_up(&["alpha"]).await;
let (daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let alpha_addr = daemons[0].bind_addr;
let ws_url = format!("ws://{alpha_addr}/api/events");
let ws_url = format!("{}/api/events", daemon.ws_base());
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url)
.await
.expect("events WS handshake");
@@ -866,23 +404,22 @@ async fn daemon_events_ws_streams_snapshot_then_session_added() {
// First frame must be a Snapshot (the WS sends it eagerly on connect).
let snapshot = next_event(&mut ws).await;
match &snapshot {
maestro_protocol::RegistryEvent::Snapshot { daemons, sessions } => {
assert!(daemons.iter().any(|d| d.daemon_id == "alpha"));
maestro_protocol::CatalogEvent::Snapshot { sessions } => {
assert!(sessions.is_empty());
}
other => panic!("expected initial Snapshot, got {other:?}"),
}
let session = client
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
.spawn_session(&create_req(cwd.path(), "sleep 30"))
.await
.expect("spawn alpha");
.expect("spawn");
// SessionAdded must show up on the WS within a reasonable window.
let saw_added = tokio::time::timeout(Duration::from_secs(5), async {
loop {
match next_event(&mut ws).await {
maestro_protocol::RegistryEvent::SessionAdded { session: s }
maestro_protocol::CatalogEvent::SessionAdded { session: s }
if s.id == session.id =>
{
return true;
@@ -902,7 +439,7 @@ async fn daemon_events_ws_streams_snapshot_then_session_added() {
}
// ---------------------------------------------------------------------------
// NAP-244 — CLI rewired through the home-daemon broker, with registry fallback
// CLI against the local daemon
// ---------------------------------------------------------------------------
/// Run the real `maestro` binary with an isolated env and capture its output.
@@ -921,18 +458,15 @@ fn run_cli(args: &[&str], envs: &[(&str, &str)]) -> std::process::Output {
}
#[tokio::test(flavor = "multi_thread")]
async fn cli_list_reads_from_home_daemon() {
// Broker path: point the CLI at the daemon's local API and DON'T configure
// a registry. Success proves the catalog read came from the home daemon —
// the registry path would have errored with NoRegistryUrl.
let (_reg, daemons, client) = bring_up(&["alpha"]).await;
async fn cli_list_reads_from_daemon() {
let (daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let session = client
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
.spawn_session(&create_req(cwd.path(), "sleep 30"))
.await
.expect("spawn");
let daemon_url = format!("http://{}", daemons[0].bind_addr);
let daemon_url = daemon.http_base();
let home = TempDir::new().unwrap();
let cfg_path = home.path().join("config.toml");
let out = run_cli(
@@ -940,7 +474,7 @@ async fn cli_list_reads_from_home_daemon() {
&[
("HOME", home.path().to_str().unwrap()),
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
("MAESTRO_HOME_DAEMON_URL", &daemon_url),
("MAESTRO_DAEMON_URL", &daemon_url),
],
);
let stdout = String::from_utf8_lossy(&out.stdout);
@@ -948,65 +482,35 @@ async fn cli_list_reads_from_home_daemon() {
assert!(out.status.success(), "list failed: {stderr}\n{stdout}");
assert!(
stdout.contains(&session.id.to_string()),
"session not listed via home daemon: {stdout}"
);
assert!(
!stderr.contains("falling back"),
"unexpected registry fallback: {stderr}"
"session not listed: {stdout}"
);
client.kill_session(session.id).await.ok();
}
#[tokio::test(flavor = "multi_thread")]
async fn cli_list_falls_back_to_registry_when_daemon_down() {
// Compat path: home daemon URL points at a dead port (connection refused).
// The CLI must warn and fall back to the configured registry, still
// returning the session.
let (reg, _daemons, client) = bring_up(&["alpha"]).await;
let cwd = TempDir::new().unwrap();
let session = client
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
.await
.expect("spawn");
async fn cli_list_fails_when_daemon_is_down() {
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
let registry_url = reg.http_base();
let home = TempDir::new().unwrap();
let cfg_path = home.path().join("config.toml");
let out = run_cli(
&["list", "--json"],
&["list"],
&[
("HOME", home.path().to_str().unwrap()),
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
("MAESTRO_HOME_DAEMON_URL", &dead_daemon),
("MAESTRO_REGISTRY_URL", &registry_url),
("MAESTRO_DAEMON_URL", &dead_daemon),
],
);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "list failed: {stderr}\n{stdout}");
assert!(
stdout.contains(&session.id.to_string()),
"session not listed via registry fallback: {stdout}"
);
assert!(
stderr.contains("falling back"),
"expected a fallback warning on stderr: {stderr}"
);
client.kill_session(session.id).await.ok();
assert!(!out.status.success(), "expected a failure with no daemon");
}
async fn next_event<S>(ws: &mut S) -> maestro_protocol::RegistryEvent
async fn next_event<S>(ws: &mut S) -> maestro_protocol::CatalogEvent
where
S: StreamExt<Item = Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
{
loop {
match ws.next().await {
Some(Ok(WsMessage::Text(t))) => {
return serde_json::from_str(&t).expect("RegistryEvent")
}
Some(Ok(WsMessage::Text(t))) => return serde_json::from_str(&t).expect("CatalogEvent"),
Some(Ok(_)) => continue,
other => panic!("WS closed waiting for event: {other:?}"),
}
+12 -34
View File
@@ -1,11 +1,11 @@
//! TUI integration tests (NAP-282).
//!
//! Exercises `CatalogStore` and the per-session `SessionTask` against a real
//! registry + daemon subprocess pair. We don't spin the full ratatui frontend
//! daemon subprocess. We don't spin the full ratatui frontend
//! here — those interactions are best tested with `TestBackend` unit tests in
//! `crates/maestro-tui/src/*.rs`. This file fills the gap between those
//! and the headless e2e suite by verifying that the TUI's wire layer plays
//! nicely with a real `maestrod`.
//! nicely with a real daemon.
//!
//! Requires `tmux` and `git` on `$PATH`.
@@ -14,26 +14,10 @@
use std::path::PathBuf;
use std::time::{Duration, Instant};
use maestro_e2e::{Daemon, Registry};
use maestro_e2e::Daemon;
use maestro_protocol::CreateSessionRequest;
use maestro_tui::catalog::CatalogStore;
const TOKEN: &str = "tui-integration-token";
async fn bring_up() -> (Registry, Daemon) {
let registry = Registry::spawn(TOKEN).await;
let daemon = Daemon::spawn("konishi", &registry.http_base(), TOKEN).await;
(registry, daemon)
}
fn http_base(daemon: &Daemon) -> String {
format!("http://{}", daemon.bind_addr)
}
fn ws_base(daemon: &Daemon) -> String {
format!("ws://{}", daemon.bind_addr)
}
async fn wait_until<F, Fut>(label: &str, deadline: Duration, mut check: F)
where
F: FnMut() -> Fut,
@@ -53,8 +37,8 @@ where
#[tokio::test(flavor = "multi_thread")]
async fn catalog_bootstrap_against_real_daemon() {
let (_registry, daemon) = bring_up().await;
let ws_url = format!("{}/api/events", ws_base(&daemon));
let daemon = Daemon::spawn().await;
let ws_url = format!("{}/api/events", daemon.ws_base());
let store = CatalogStore::default();
let bg = store.clone();
@@ -64,17 +48,14 @@ async fn catalog_bootstrap_against_real_daemon() {
.await
.expect("initial snapshot");
let snap = store.snapshot();
assert_eq!(snap.daemons.len(), 1, "self-daemon snapshot");
assert_eq!(snap.daemons[0].daemon_id, "konishi");
assert!(snap.sessions.is_empty());
assert!(store.snapshot().sessions.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn post_session_propagates_through_events_ws() {
let (_registry, daemon) = bring_up().await;
let ws_url = format!("{}/api/events", ws_base(&daemon));
let http_base = http_base(&daemon);
let daemon = Daemon::spawn().await;
let ws_url = format!("{}/api/events", daemon.ws_base());
let http_base = daemon.http_base();
let store = CatalogStore::default();
let bg = store.clone();
@@ -86,7 +67,6 @@ async fn post_session_propagates_through_events_ws() {
let cwd = PathBuf::from(daemon.home());
let req = CreateSessionRequest {
daemon_id: "konishi".into(),
cwd,
command: "bash".into(),
env: Default::default(),
@@ -110,15 +90,14 @@ async fn post_session_propagates_through_events_ws() {
let snap = store.snapshot();
assert_eq!(snap.sessions.len(), 1);
assert_eq!(snap.sessions[0].daemon_id, "konishi");
assert_eq!(snap.sessions[0].command, "bash");
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_session_propagates_removal() {
let (_registry, daemon) = bring_up().await;
let ws_url = format!("{}/api/events", ws_base(&daemon));
let http_base = http_base(&daemon);
let daemon = Daemon::spawn().await;
let ws_url = format!("{}/api/events", daemon.ws_base());
let http_base = daemon.http_base();
let store = CatalogStore::default();
let bg = store.clone();
@@ -130,7 +109,6 @@ async fn delete_session_propagates_removal() {
let cwd = PathBuf::from(daemon.home());
let req = CreateSessionRequest {
daemon_id: "konishi".into(),
cwd,
command: "bash".into(),
env: Default::default(),
+1 -3
View File
@@ -44,9 +44,7 @@ impl SystemctlAction {
/// Build the `cargo install` argument vector.
///
/// `features`, when `Some`, restricts the build to an explicit feature set via
/// `--no-default-features --features <list>`. The installer uses this to skip
/// the `registry` feature of the umbrella `maestro` crate: a host runs the CLI
/// + daemon (+ TUI); the registry is a separate, centrally-deployed service.
/// `--no-default-features --features <list>`.
pub fn cargo_install_args(
source: &CargoSource,
crate_name: &str,
+17 -122
View File
@@ -1,6 +1,5 @@
//! `maestro-install` — interactive wizard that installs the unified `maestro`
//! binary via `cargo install`, optionally drops a systemd user unit, and
//! optionally kicks off `maestro daemon configure`.
//! binary via `cargo install` and optionally drops a systemd user unit.
//!
//! Intended bootstrap path:
//!
@@ -22,13 +21,9 @@ use maestro_installer::{
};
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
/// Unified `maestro` crate. The installer builds it with the host feature set
/// (CLI + daemon + TUI); the `registry` feature is left out (see
/// `MAESTRO_HOST_FEATURES`).
/// Unified `maestro` crate, built with the full host feature set.
const MAESTRO_CRATE: &str = "maestro";
/// Features installed for a host: CLI + per-host daemon + TUI. The `registry`
/// feature is deliberately omitted — the registry is a separate, centrally-
/// deployed service, not something a host install should compile or run.
/// Features installed for a host: CLI + per-host daemon + TUI.
const MAESTRO_HOST_FEATURES: &str = "cli,daemon,tui";
const WEB_CRATE: &str = "maestro-web";
const SYSTEMD_PROBE: &str = "/run/systemd/system";
@@ -57,7 +52,7 @@ struct Args {
#[arg(long)]
no_locked: bool,
/// Don't prompt — accept all defaults (install + systemd + configure).
/// Don't prompt — accept all defaults (install + systemd).
#[arg(long, short)]
yes: bool,
@@ -65,10 +60,6 @@ struct Args {
#[arg(long)]
no_systemd: bool,
/// Skip the `maestro daemon configure` step.
#[arg(long)]
no_configure: bool,
/// Override the ExecStart path written to the systemd unit.
/// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`).
#[arg(long, value_name = "PATH")]
@@ -107,51 +98,25 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
// 1. Install the unified binary (NAP-245), minus the registry feature.
// 1. Install the unified binary (NAP-245).
let locked = !args.no_locked;
install_crate(&source, MAESTRO_CRATE, locked, Some(MAESTRO_HOST_FEATURES))?;
// 2. Offer to run `maestro daemon configure` now (must happen before systemd
// start, since the daemon refuses to run without registry_url / token).
// Re-runs of the wizard auto-skip when the unified config already has a
// [daemon] section; that's what makes "re-run to update" pleasant. In
// non-interactive mode (--yes / no TTY) we also skip — `maestro daemon
// configure` would prompt for required fields and we don't want to
// surprise scripts.
let configured = if let Some(path) = maestro_config_path() {
if daemon_section_present(&path) {
eprintln!(
"\n[daemon] section already present in {} — skipping configure step.",
path.display()
);
eprintln!("(run `maestro daemon configure --force` to rewrite it.)");
true
} else {
run_configure_step(&args, interactive)?
}
} else {
run_configure_step(&args, interactive)?
};
// 3. systemd integration.
// 2. systemd integration.
let systemd_detected = is_systemd_host(Path::new(SYSTEMD_PROBE));
if args.no_systemd {
eprintln!("\nSkipping systemd step (--no-systemd).");
} else if !systemd_detected {
eprintln!("\nNo systemd detected ({SYSTEMD_PROBE} missing). Skipping unit install.");
} else {
handle_systemd(&args, interactive, configured)?;
handle_systemd(&args, interactive)?;
}
// 4. Optional: maestro-web (NAP-249). Re-runs auto-detect an existing
// 3. Optional: maestro-web (NAP-249). Re-runs auto-detect an existing
// install and refresh rather than re-prompting, matching the daemon flow.
let web_installed = handle_web(&args, interactive, systemd_detected)?;
print_summary(
configured,
systemd_detected && !args.no_systemd,
web_installed,
);
print_summary(systemd_detected && !args.no_systemd, web_installed);
Ok(())
}
@@ -170,12 +135,9 @@ fn print_banner(source: &CargoSource) {
eprintln!("maestro installer");
eprintln!();
eprintln!("This wizard will:");
eprintln!(
" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI; registry runs separately)"
);
eprintln!(" 2. (optional) run `maestro daemon configure` to bootstrap the config file");
eprintln!(" 3. (optional) install a systemd user unit and enable it");
eprintln!(" 4. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
eprintln!(" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI)");
eprintln!(" 2. (optional) install a systemd user unit and enable it");
eprintln!(" 3. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
eprintln!();
match source {
CargoSource::Git { url, branch } => {
@@ -205,65 +167,7 @@ fn install_crate(
Ok(())
}
fn maestro_config_path() -> Option<PathBuf> {
Some(dirs::config_dir()?.join("maestro").join("config.toml"))
}
/// True iff the unified file exists and already has a `[daemon]` section.
/// Used to skip the configure prompt on re-runs without forcing the user
/// to manually delete a co-located `[registry]` / `[cli]` section first.
fn daemon_section_present(path: &Path) -> bool {
if !path.exists() {
return false;
}
let Ok(body) = std::fs::read_to_string(path) else {
return false;
};
body.lines().any(|l| l.trim_start().starts_with("[daemon]"))
}
fn run_configure_step(args: &Args, interactive: bool) -> Result<bool, Box<dyn std::error::Error>> {
let want = interactive
&& !args.no_configure
&& confirm(
"Run `maestro daemon configure` now (writes ~/.config/maestro/config.toml)?",
true,
)?;
if want {
run_configure_passthrough()
} else {
Ok(false)
}
}
fn run_configure_passthrough() -> Result<bool, Box<dyn std::error::Error>> {
// Run the freshly-installed `maestro daemon configure` interactively. We
// don't hard-code the path; rely on it being on PATH after `cargo install`.
eprintln!("\n==> maestro daemon configure");
let status = Command::new("maestro")
.args(["daemon", "configure"])
.status();
match status {
Ok(s) if s.success() => Ok(true),
Ok(s) => {
eprintln!("`maestro daemon configure` exited {s}");
Ok(false)
}
Err(e) => {
eprintln!(
"couldn't launch `maestro`: {e}. Make sure ~/.cargo/bin is on PATH, \
then run `maestro daemon configure` manually."
);
Ok(false)
}
}
}
fn handle_systemd(
args: &Args,
interactive: bool,
configured: bool,
) -> Result<(), Box<dyn std::error::Error>> {
fn handle_systemd(args: &Args, interactive: bool) -> Result<(), Box<dyn std::error::Error>> {
let unit_path = user_unit_path().ok_or("could not resolve ~/.config/systemd/user/")?;
let exec_start = match &args.exec_start {
Some(p) => p.clone(),
@@ -326,12 +230,6 @@ fn handle_systemd(
} else {
let want = if !interactive {
false
} else if !configured {
eprintln!(
"Note: starting now will fail until you run `maestro daemon configure` \
(no registry_url / token yet)."
);
confirm("Start maestro.service now anyway?", false)?
} else {
confirm("Start maestro.service now?", true)?
};
@@ -421,10 +319,10 @@ fn handle_web(
} else {
eprintln!(
"\nOptional: install the browser UI (`maestro-web`)?\n \
Reverse-proxies a local maestro daemon and serves the SPA at \
Reverse-proxies the local maestro daemon and serves the SPA at \
http://127.0.0.1:8080/ by default.\n \
Edit [web].bind_addr in ~/.config/maestro/config.toml \
to expose it to your perimeter (e.g. WireGuard IP)."
to expose it on your LAN/VPN address."
);
confirm("Install maestro-web?", true)?
};
@@ -549,21 +447,18 @@ fn confirm(prompt: &str, default_yes: bool) -> io::Result<bool> {
})
}
fn print_summary(configured: bool, systemd_enabled: bool, web_installed: bool) {
fn print_summary(systemd_enabled: bool, web_installed: bool) {
eprintln!();
eprintln!("Done.");
eprintln!();
eprintln!("Next steps:");
if !configured {
eprintln!(" • Run `maestro daemon configure` to bootstrap your config file.");
}
if systemd_enabled {
eprintln!(" • Check status: `systemctl --user status maestro`");
eprintln!(" • Logs: `journalctl --user -u maestro -f`");
} else {
eprintln!(" • Start the daemon: `maestro daemon`");
}
eprintln!(" • Set the CLI's registry: `maestro set-registry <URL>`");
eprintln!(" • Open the TUI: `maestro tui`");
if web_installed {
if systemd_enabled {
eprintln!(
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "maestro-names"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Orchestra-flavoured session-handle generator (adjective × instrument) shared across Maestro"
[dependencies]
rand.workspace = true
[dev-dependencies]
rand = { workspace = true, features = ["small_rng"] }
-214
View File
@@ -1,214 +0,0 @@
//! `maestro-names` — the shared session-handle generator.
//!
//! Maestro is a conductor's podium for every shell you care about, so the
//! handles it hands out are drawn from the orchestra pit: a musical adjective
//! (tempo, dynamics, articulation) paired with an instrument or musical form.
//! The result reads like a marking on a score — `allegro-cello`,
//! `crescendo-oboe`, `staccato-viola`.
//!
//! ## Determinism
//!
//! Every pure path takes an **injected** [`rand::Rng`]; nothing here reaches
//! for `thread_rng()` or the clock, so seeded tests are reproducible (a repo
//! invariant). [`generate_thread`] is the one impure convenience wrapper for
//! production callers that just want a name and don't care about the seed.
//!
//! The Rust generator is the source of truth. If the web UI wants an instant
//! client-side reroll it can mirror [`ADJECTIVES`] / [`NOUNS`] verbatim, but
//! the wordlists live here.
use rand::seq::SliceRandom;
use rand::Rng;
/// Musical adjectives — tempo, dynamics, and articulation markings.
pub const ADJECTIVES: &[&str] = &[
"allegro",
"andante",
"adagio",
"presto",
"vivace",
"largo",
"grave",
"lento",
"moderato",
"crescendo",
"staccato",
"legato",
"forte",
"dolce",
"agitato",
"cantabile",
"sostenuto",
"tremolo",
"rubato",
"marcato",
"brillante",
"maestoso",
"grazioso",
"espressivo",
"tranquillo",
"animato",
"spirito",
"sotto",
"vivo",
"feroce",
];
/// Musical nouns — instruments and forms from the concert hall.
pub const NOUNS: &[&str] = &[
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
"motif", "overture",
];
/// Generate a single `adjective-noun` handle from an injected RNG.
///
/// ```
/// use rand::SeedableRng;
/// let mut rng = rand::rngs::SmallRng::seed_from_u64(7);
/// let name = maestro_names::generate(&mut rng);
/// assert!(name.contains('-'));
/// ```
pub fn generate<R: Rng + ?Sized>(rng: &mut R) -> String {
// Both lists are compile-time non-empty (asserted in tests), so the
// `choose` calls cannot return `None`.
let adj = ADJECTIVES.choose(rng).expect("ADJECTIVES is non-empty");
let noun = NOUNS.choose(rng).expect("NOUNS is non-empty");
format!("{adj}-{noun}")
}
/// Generate a handle that isn't already taken.
///
/// Draws `adjective-noun` candidates from `rng`; if `is_taken` rejects one, a
/// numeric uniqueness suffix is appended and incremented (`-2`, `-3`, …) until
/// a free handle is found. The bare form is preferred — the suffix only
/// appears on collision, matching the score-marking aesthetic.
///
/// `is_taken` is the single source of "already used"; given the same RNG and
/// the same predicate the output is fully deterministic.
pub fn generate_unique<R, F>(rng: &mut R, is_taken: F) -> String
where
R: Rng + ?Sized,
F: Fn(&str) -> bool,
{
let base = generate(rng);
if !is_taken(&base) {
return base;
}
// Start at 2 so the first duplicate reads `name`, `name-2`, `name-3` …
for suffix in 2u64.. {
let candidate = format!("{base}-{suffix}");
if !is_taken(&candidate) {
return candidate;
}
}
unreachable!("u64 suffix space is effectively inexhaustible")
}
/// Impure convenience wrapper: generate a handle from the thread RNG.
///
/// For production callers that just want a name. Tests and any deterministic
/// path should use [`generate`] / [`generate_unique`] with a seeded RNG.
pub fn generate_thread() -> String {
generate(&mut rand::thread_rng())
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::SmallRng;
use rand::SeedableRng;
use std::collections::HashSet;
fn seeded(seed: u64) -> SmallRng {
SmallRng::seed_from_u64(seed)
}
#[test]
fn wordlists_are_non_empty() {
assert!(!ADJECTIVES.is_empty());
assert!(!NOUNS.is_empty());
}
#[test]
fn wordlists_have_no_duplicates() {
assert_eq!(
ADJECTIVES.iter().collect::<HashSet<_>>().len(),
ADJECTIVES.len(),
"duplicate adjective"
);
assert_eq!(
NOUNS.iter().collect::<HashSet<_>>().len(),
NOUNS.len(),
"duplicate noun"
);
}
#[test]
fn generate_is_adjective_dash_noun() {
let mut rng = seeded(1);
let name = generate(&mut rng);
let (adj, noun) = name.split_once('-').expect("must contain a single dash");
assert!(ADJECTIVES.contains(&adj), "{adj} not an adjective");
assert!(NOUNS.contains(&noun), "{noun} not a noun");
}
#[test]
fn generation_is_deterministic_under_seed() {
// Same seed → identical sequence; this is the core repo invariant.
let mut a = seeded(42);
let mut b = seeded(42);
for _ in 0..50 {
assert_eq!(generate(&mut a), generate(&mut b));
}
}
#[test]
fn different_seeds_diverge() {
// Not a guarantee for a single draw, but across a run the sequences
// must differ — otherwise the RNG isn't actually being threaded.
let mut a = seeded(1);
let mut b = seeded(2);
let seq_a: Vec<_> = (0..30).map(|_| generate(&mut a)).collect();
let seq_b: Vec<_> = (0..30).map(|_| generate(&mut b)).collect();
assert_ne!(seq_a, seq_b);
}
#[test]
fn unique_returns_bare_name_when_free() {
let mut rng = seeded(3);
let name = generate_unique(&mut rng, |_| false);
assert!(
!name.ends_with("-2"),
"no suffix expected when nothing taken"
);
assert!(name.contains('-'));
}
#[test]
fn unique_appends_incrementing_suffix_on_collision() {
// Reserve the bare name and the first two suffixed variants; the
// generator must walk past them to `-4`.
let mut probe = seeded(9);
let base = generate(&mut probe);
let taken: HashSet<String> = [base.clone(), format!("{base}-2"), format!("{base}-3")]
.into_iter()
.collect();
let mut rng = seeded(9);
let name = generate_unique(&mut rng, |n| taken.contains(n));
assert_eq!(name, format!("{base}-4"));
}
#[test]
fn unique_suffix_starts_at_two() {
let mut probe = seeded(11);
let base = generate(&mut probe);
let taken: HashSet<String> = [base.clone()].into_iter().collect();
let mut rng = seeded(11);
let name = generate_unique(&mut rng, |n| taken.contains(n));
assert_eq!(name, format!("{base}-2"));
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Shared wire types for Maestro daemon/registry/client communication"
description = "Shared wire types for Maestro daemon/client communication"
[dependencies]
chrono.workspace = true
File diff suppressed because it is too large Load Diff
-35
View File
@@ -1,35 +0,0 @@
[package]
name = "maestro-registry"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Always-on registry — daemon presence index and session catalog"
[lib]
path = "src/lib.rs"
[dependencies]
axum.workspace = true
chrono.workspace = true
dirs.workspace = true
futures-util.workspace = true
jsonwebtoken.workspace = true
maestro-config.workspace = true
maestro-protocol.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies"] }
tempfile.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "test-util"] }
tokio-tungstenite.workspace = true
-172
View File
@@ -1,172 +0,0 @@
//! Cookie-based auth for the client-facing HTTP/WS surface.
//!
//! On the first request the registry sets an httpOnly cookie whose value is a
//! JWT signed with the shared token (`MAESTRO_TOKEN`, HS256). Subsequent requests
//! must present a cookie whose signature verifies; missing or tampered
//! cookies get a fresh cookie issued (effectively re-bootstrapping a
//! first-time client) and are still allowed through. The cookie is the second
//! factor against anyone routing to the bind address; per spec §Auth the
//! perimeter is the operator's network.
//!
//! Implementation notes:
//!
//! - We reuse `jsonwebtoken` so we don't pull in a cookie/HMAC crate just for
//! this one signature.
//! - The middleware passes the inbound `Cookie` header through to the inner
//! handler unchanged, and adds a `Set-Cookie` response header if the inbound
//! cookie was missing or invalid.
use std::sync::Arc;
use axum::body::Body;
use axum::extract::State;
use axum::http::{header, HeaderValue, Request, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use maestro_protocol::REGISTRY_ISSUER;
use serde::{Deserialize, Serialize};
/// Name of the auth cookie surfaced to web clients. Defined in
/// `maestro-protocol` so the CLI agrees; re-exported here for local callers.
pub use maestro_protocol::{extract_cookie, COOKIE_NAME};
/// 30 days. Cookie is the second factor — long enough not to log users out;
/// short enough to bound the blast radius if the secret rotates.
const COOKIE_TTL_DAYS: i64 = 30;
/// `aud` claim that distinguishes a registry auth cookie from every other
/// registry-signed JWT (pair-attach credentials carry a daemon-id audience).
/// Verifiers bind to it so a token minted for another purpose can't be replayed
/// as a cookie even though both share the signing secret (NAP-266).
const COOKIE_AUDIENCE: &str = "maestro-cookie";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CookieClaims {
/// Issuer — always [`REGISTRY_ISSUER`].
iss: String,
/// Audience — always [`COOKIE_AUDIENCE`].
aud: String,
/// Subject — currently always `"client"` because this is single-user.
sub: String,
exp: i64,
iat: i64,
}
/// Issue a fresh cookie value (a JWT) signed with `secret`.
pub fn issue_cookie(secret: &str) -> String {
let now = Utc::now();
let claims = CookieClaims {
iss: REGISTRY_ISSUER.into(),
aud: COOKIE_AUDIENCE.into(),
sub: "client".into(),
iat: now.timestamp(),
exp: (now + Duration::days(COOKIE_TTL_DAYS)).timestamp(),
};
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, &claims, &key).expect("encode auth cookie")
}
/// Returns true if `value` is a JWT validly signed by `secret` *and* bound to
/// this cookie's issuer + audience — so a pair-attach JWT (same secret,
/// different `aud`) is rejected here (NAP-266).
pub fn verify_cookie(secret: &str, value: &str) -> bool {
let key = DecodingKey::from_secret(secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&[REGISTRY_ISSUER]);
validation.set_audience(&[COOKIE_AUDIENCE]);
decode::<CookieClaims>(value, &key, &validation).is_ok()
}
/// Format the full `Set-Cookie` header value.
fn set_cookie_header(value: &str) -> String {
format!(
"{name}={value}; HttpOnly; SameSite=Strict; Path=/; Max-Age={age}",
name = COOKIE_NAME,
age = COOKIE_TTL_DAYS * 24 * 60 * 60,
)
}
/// Shared state for the auth middleware: just the signing secret.
#[derive(Debug, Clone)]
pub struct AuthState {
pub token: Arc<String>,
}
/// Axum middleware that enforces / bootstraps the client auth cookie.
///
/// - Valid cookie present → pass through, no `Set-Cookie`.
/// - Cookie missing → issue a fresh one and pass through.
/// - Cookie present but tampered/expired → issue a fresh one and pass through.
/// (Spec is intentionally lenient — perimeter is the operator's network.)
pub async fn cookie_middleware(
State(state): State<AuthState>,
request: Request<Body>,
next: Next,
) -> Response {
let token = state.token.clone();
let inbound_cookie = request
.headers()
.get(header::COOKIE)
.and_then(|h| h.to_str().ok())
.and_then(|v| extract_cookie(v, COOKIE_NAME).map(|s| s.to_string()));
let needs_new_cookie = match &inbound_cookie {
Some(value) => !verify_cookie(token.as_str(), value),
None => true,
};
let mut response = next.run(request).await;
if needs_new_cookie {
let fresh = issue_cookie(token.as_str());
match HeaderValue::from_str(&set_cookie_header(&fresh)) {
Ok(hv) => {
response.headers_mut().append(header::SET_COOKIE, hv);
}
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "cookie encode failed").into_response();
}
}
}
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
let token = "shared-secret-aaaa";
let value = issue_cookie(token);
assert!(verify_cookie(token, &value));
assert!(!verify_cookie("different-secret", &value));
}
/// NAP-266: a pair-attach JWT is signed with the same shared secret but is
/// bound to a daemon-id audience, so it must NOT validate as an auth cookie.
#[test]
fn verify_cookie_rejects_pair_attach_jwt() {
let secret = "shared-secret-bbbb";
let pair =
crate::jwt::issue_pair_attach_jwt(secret, "laptop", "konishi", uuid::Uuid::new_v4())
.expect("issue pair jwt");
assert!(
!verify_cookie(secret, &pair),
"a pair-attach JWT must not be accepted as an auth cookie"
);
}
#[test]
fn set_cookie_header_includes_attributes() {
let h = set_cookie_header("abc");
assert!(h.contains("HttpOnly"));
assert!(h.contains("SameSite=Strict"));
assert!(h.contains("Path=/"));
assert!(h.starts_with("maestro_session=abc;"));
}
}
-251
View File
@@ -1,251 +0,0 @@
//! Client-facing HTTP API routes (NAP-215).
//!
//! Mounts under the same router as the daemon-facing `WS /daemon`. The cookie
//! middleware in [`crate::auth`] gates everything except the WS upgrade for
//! `/daemon` (daemons authenticate via the shared token in `Hello`).
use std::sync::Arc;
use std::time::Duration;
use axum::extract::{ws::WebSocketUpgrade, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get};
use axum::{Json, Router};
use maestro_protocol::{ConnectInfo, CreateSessionRequest, DaemonInfo, DirListing, Session};
use serde::Deserialize;
use uuid::Uuid;
use crate::events::events_ws;
use crate::jwt::issue_attach_jwt;
use crate::state::{Registry, RegistryError, RPC_TIMEOUT};
/// Shared state plumbed into client-facing handlers.
#[derive(Debug, Clone)]
pub struct ClientApiState {
pub registry: Registry,
pub token: Arc<String>,
pub rpc_timeout: Duration,
}
/// Mount client-facing routes on the given router. Caller is responsible for
/// applying the cookie middleware (we do that in [`crate::server::router`]).
pub fn routes(state: ClientApiState) -> Router {
// The registry serves no web UI — that's `maestro-web`'s job (see
// connection-orchestration spec §"UI hosting"). Only the JSON/WS API here.
Router::new()
.route("/api/daemons", get(list_daemons))
.route("/api/daemons/:id/dirs", get(list_dir))
.route("/api/sessions", get(list_sessions).post(create_session))
.route("/api/sessions/:id", axum::routing::delete(delete_session))
.route("/api/sessions/:id/connect-info", get(session_connect_info))
.route("/api/events", any(events_ws_handler))
.with_state(state)
}
async fn list_daemons(State(s): State<ClientApiState>) -> Json<Vec<DaemonInfo>> {
let infos: Vec<DaemonInfo> = s
.registry
.daemons()
.await
.into_iter()
.map(Into::into)
.collect();
Json(infos)
}
async fn list_sessions(State(s): State<ClientApiState>) -> Json<Vec<Session>> {
Json(s.registry.sessions().await)
}
/// Query string for `GET /api/daemons/:id/dirs`. `path` is the directory to
/// list; empty/`~`/relative are resolved against the target daemon's home dir.
#[derive(Debug, Deserialize)]
struct DirQuery {
#[serde(default)]
path: String,
}
/// List one directory's immediate subdirectories on `daemon_id`'s host, for the
/// web spawn form's working-directory autocomplete (NAP-329). Drives a `ListDir`
/// RPC to the target daemon, same machinery + error mapping as `create_session`.
async fn list_dir(
State(s): State<ClientApiState>,
Path(daemon_id): Path<String>,
Query(q): Query<DirQuery>,
) -> Response {
let outcome =
tokio::time::timeout(s.rpc_timeout, s.registry.list_dir(&daemon_id, q.path)).await;
match outcome {
Err(_) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(RegistryError::DaemonNotConnected(_))) => (
StatusCode::NOT_FOUND,
Json(error_body("unknown_daemon", "daemon is not connected")),
)
.into_response(),
Ok(Err(RegistryError::Timeout(_))) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(e)) => (
StatusCode::BAD_GATEWAY,
Json(error_body("transport", &e.to_string())),
)
.into_response(),
Ok(Ok(Err(rpc_err))) => (
dir_error_status(&rpc_err.0.code),
Json(error_body(&rpc_err.0.code, &rpc_err.0.message)),
)
.into_response(),
Ok(Ok(Ok(listing))) => Json::<DirListing>(listing).into_response(),
}
}
/// HTTP status for a daemon-reported directory-listing error code (NAP-329).
fn dir_error_status(code: &str) -> StatusCode {
match code {
"not_found" => StatusCode::NOT_FOUND,
"permission_denied" => StatusCode::FORBIDDEN,
"not_a_directory" => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_GATEWAY,
}
}
async fn create_session(
State(s): State<ClientApiState>,
Json(body): Json<CreateSessionRequest>,
) -> Response {
let daemon = body.daemon_id.clone();
let outcome =
tokio::time::timeout(s.rpc_timeout, s.registry.create_session(&daemon, body)).await;
match outcome {
Err(_) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(RegistryError::DaemonNotConnected(_))) => (
StatusCode::NOT_FOUND,
Json(error_body("unknown_daemon", "daemon is not connected")),
)
.into_response(),
Ok(Err(RegistryError::Timeout(_))) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(e)) => (
StatusCode::BAD_GATEWAY,
Json(error_body("transport", &e.to_string())),
)
.into_response(),
Ok(Ok(Err(rpc_err))) => (
StatusCode::BAD_GATEWAY,
Json(error_body(&rpc_err.0.code, &rpc_err.0.message)),
)
.into_response(),
Ok(Ok(Ok(session))) => (StatusCode::CREATED, Json(session)).into_response(),
}
}
async fn delete_session(State(s): State<ClientApiState>, Path(id): Path<Uuid>) -> Response {
let Some(daemon) = s.registry.daemon_for_session(id).await else {
return (
StatusCode::NOT_FOUND,
Json(error_body("unknown_session", "no such session")),
)
.into_response();
};
let outcome = tokio::time::timeout(s.rpc_timeout, s.registry.kill_session(&daemon, id)).await;
match outcome {
Err(_) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(RegistryError::DaemonNotConnected(_))) => (
StatusCode::NOT_FOUND,
Json(error_body("unknown_daemon", "daemon is not connected")),
)
.into_response(),
Ok(Err(RegistryError::Timeout(_))) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(e)) => (
StatusCode::BAD_GATEWAY,
Json(error_body("transport", &e.to_string())),
)
.into_response(),
Ok(Ok(Err(rpc_err))) => (
StatusCode::BAD_GATEWAY,
Json(error_body(&rpc_err.0.code, &rpc_err.0.message)),
)
.into_response(),
Ok(Ok(Ok(()))) => StatusCode::NO_CONTENT.into_response(),
}
}
async fn session_connect_info(State(s): State<ClientApiState>, Path(id): Path<Uuid>) -> Response {
let Some(daemon) = s.registry.daemon_for_session(id).await else {
return (
StatusCode::NOT_FOUND,
Json(error_body("unknown_session", "no such session")),
)
.into_response();
};
let Some(daemon_url) = s.registry.daemon_url(&daemon).await else {
return (
StatusCode::NOT_FOUND,
Json(error_body("unknown_daemon", "owning daemon is offline")),
)
.into_response();
};
let jwt = match issue_attach_jwt(s.token.as_str(), &daemon, id) {
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(error_body("jwt_failed", &e.to_string())),
)
.into_response();
}
};
Json(ConnectInfo { daemon_url, jwt }).into_response()
}
async fn events_ws_handler(
State(s): State<ClientApiState>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
let registry = s.registry.clone();
ws.on_upgrade(move |socket| async move {
let _ = events_ws(socket, registry).await;
})
}
fn error_body(code: &str, message: &str) -> serde_json::Value {
serde_json::json!({
"error": {
"code": code,
"message": message,
},
})
}
/// Convenience: produce a `ClientApiState` from a `Registry` and shared token.
/// Uses the production [`RPC_TIMEOUT`].
pub fn state_for(registry: Registry, token: String) -> ClientApiState {
ClientApiState {
registry,
token: Arc::new(token),
rpc_timeout: RPC_TIMEOUT,
}
}
-59
View File
@@ -1,59 +0,0 @@
//! `WS /api/events` — registry-side fan-out of catalog + presence deltas.
//!
//! On connect we immediately push a [`RegistryEvent::Snapshot`] of the current
//! state, then forward every broadcast event until the client disconnects.
//! Lagged subscribers receive a [`RegistryEvent::Lagged`] notice with the
//! count of dropped frames — the convention is for the client to re-fetch
//! `GET /api/sessions` on receipt.
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::RegistryEvent;
use tokio::sync::broadcast::error::RecvError;
use crate::state::Registry;
pub async fn events_ws(socket: WebSocket, registry: Registry) -> Result<(), axum::Error> {
let (mut sink, mut stream) = socket.split();
let (snapshot, mut rx) = registry.subscribe().await;
sink.send(Message::Text(
serde_json::to_string(&snapshot).expect("snapshot"),
))
.await?;
loop {
tokio::select! {
ev = rx.recv() => {
match ev {
Ok(event) => {
let text = serde_json::to_string(&event).expect("event encode");
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
Err(RecvError::Lagged(n)) => {
let text = serde_json::to_string(&RegistryEvent::Lagged { skipped: n })
.expect("lagged encode");
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
Err(RecvError::Closed) => break,
}
}
client_frame = stream.next() => {
// Treat anything from the client as either a close or a ping
// we don't care about; the events WS is server-push only.
match client_frame {
None => break,
Some(Err(_)) => break,
Some(Ok(Message::Close(_))) => break,
Some(Ok(_)) => {}
}
}
}
}
Ok(())
}
-325
View File
@@ -1,325 +0,0 @@
//! Per-connection handler for the `WS /daemon` endpoint.
//!
//! The handler is split from the axum-specific glue so it can be exercised
//! directly with an in-process pair of channels in tests. The axum side just
//! adapts a `WebSocket` into the trait below.
use std::time::Duration;
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{DaemonInbound, DaemonOutbound, ErrorInfo, RegistryEvent};
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::mpsc;
use crate::state::Registry;
/// Bound the registry's per-daemon outbound queue. 64 in-flight R→D requests
/// is plenty for a single daemon and bounds memory use under back-pressure.
const OUTBOUND_BUFFER: usize = 64;
/// Run the handshake + message loop on a freshly-accepted WebSocket. Returns
/// the `daemon_id` once the connection terminates (for logging).
///
/// `heartbeat_timeout` is the maximum gap allowed between inbound frames —
/// once exceeded, the connection is dropped and the daemon's sessions are
/// removed from the catalog. Production code passes
/// [`crate::state::HEARTBEAT_TIMEOUT`]; tests use a short value.
pub async fn handle_socket(
socket: WebSocket,
registry: Registry,
expected_token: &str,
heartbeat_timeout: Duration,
) -> Option<String> {
let (mut sink, mut stream) = socket.split();
let hello = match read_hello(&mut stream).await {
Some(h) => h,
None => return None,
};
let (daemon_id, hostname, version, token, daemon_url, home_dir) = match hello {
DaemonOutbound::Hello {
daemon_id,
hostname,
version,
token,
daemon_url,
home_dir,
} => (daemon_id, hostname, version, token, daemon_url, home_dir),
_ => {
let _ = send_error(&mut sink, "expected_hello", "first message must be Hello").await;
return None;
}
};
if token != expected_token {
let _ = send_error(&mut sink, "auth_failed", "invalid token").await;
return None;
}
let (out_tx, mut out_rx) = mpsc::channel::<DaemonInbound>(OUTBOUND_BUFFER);
// Hold the fencing token for the life of this connection; only a deregister
// carrying it may tear down the entry we just installed (NAP-258).
let connection_token = registry
.register(
daemon_id.clone(),
hostname,
version,
daemon_url,
home_dir,
out_tx.clone(),
)
.await;
let _ = sink
.send(Message::Text(
serde_json::to_string(&DaemonInbound::Ack).expect("ack serialize"),
))
.await;
// NAP-246: forward peer-filtered catalog deltas to the daemon. Initial
// snapshot is pushed eagerly; live deltas flow through `catalog_task` →
// `out_tx` so they share the existing outbound writer in the select! loop
// (any extra sink writer would contend with the WS sink+stream coupling
// axum imposes and stall the inbound reader — see history of this file).
let (snapshot, mut catalog_rx) = registry.subscribe().await;
if let Some(peer_snapshot) = snapshot.for_peer(&daemon_id) {
if out_tx
.send(DaemonInbound::CatalogEvent {
event: peer_snapshot,
})
.await
.is_err()
{
registry.deregister(&daemon_id, connection_token).await;
return Some(daemon_id);
}
}
let catalog_id = daemon_id.clone();
let catalog_out = out_tx.clone();
let catalog_task = tokio::spawn(async move {
loop {
let ev = match catalog_rx.recv().await {
Ok(e) => e.for_peer(&catalog_id),
Err(RecvError::Lagged(n)) => Some(RegistryEvent::Lagged { skipped: n }),
Err(RecvError::Closed) => return,
};
let Some(event) = ev else { continue };
if catalog_out
.send(DaemonInbound::CatalogEvent { event })
.await
.is_err()
{
return;
}
}
});
loop {
tokio::select! {
inbound = tokio::time::timeout(heartbeat_timeout, stream.next()) => {
match inbound {
Err(_) => {
tracing::info!(%daemon_id, "heartbeat timeout — dropping");
break;
}
Ok(None) => break,
Ok(Some(Err(e))) => {
tracing::info!(%daemon_id, ?e, "ws read error");
break;
}
Ok(Some(Ok(Message::Close(_)))) => break,
Ok(Some(Ok(Message::Text(text)))) => {
if !dispatch_text(&registry, &daemon_id, expected_token, &text).await {
break;
}
}
Ok(Some(Ok(Message::Binary(_) | Message::Ping(_) | Message::Pong(_)))) => {}
}
}
Some(msg) = out_rx.recv() => {
let text = serde_json::to_string(&msg).expect("serialize daemon-inbound");
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
}
}
catalog_task.abort();
registry.deregister(&daemon_id, connection_token).await;
Some(daemon_id)
}
/// Reads the first text frame, parses it as a `DaemonOutbound`, returns it.
async fn read_hello(
stream: &mut futures_util::stream::SplitStream<WebSocket>,
) -> Option<DaemonOutbound> {
match tokio::time::timeout(Duration::from_secs(5), stream.next()).await {
Ok(Some(Ok(Message::Text(t)))) => serde_json::from_str(&t).ok(),
_ => None,
}
}
async fn dispatch_text(
registry: &Registry,
daemon_id: &str,
shared_secret: &str,
text: &str,
) -> bool {
let parsed: DaemonOutbound = match serde_json::from_str(text) {
Ok(p) => p,
Err(e) => {
// A single malformed frame must not tear down the WS (and wipe this
// daemon's catalog) — log and skip it (NAP-269).
tracing::warn!(%daemon_id, error = %e, "malformed message — skipping");
return true;
}
};
match parsed {
DaemonOutbound::Hello { .. } => {
tracing::warn!(%daemon_id, "second Hello on live connection — ignoring");
}
DaemonOutbound::SessionsSnapshot { sessions } => {
registry.apply_snapshot(daemon_id, sessions).await;
}
DaemonOutbound::SessionAdded { session } => {
registry.add_session(daemon_id, session).await;
}
DaemonOutbound::SessionRemoved { id } => {
registry.remove_session(daemon_id, id).await;
}
DaemonOutbound::SessionUpdated { session } => {
registry.update_session(daemon_id, session).await;
}
DaemonOutbound::Heartbeat { perimeter } => {
// Per-read timeout above is the heartbeat watchdog; capture the
// perimeter advertisement so the broker can hand it back as a T2
// candidate (NAP-247).
registry.update_perimeter(daemon_id, perimeter).await;
}
DaemonOutbound::CreateSessionResult { reply_to, result } => {
registry.complete_create(reply_to, result).await;
}
DaemonOutbound::KillSessionResult { reply_to, result } => {
registry.complete_kill(reply_to, result).await;
}
DaemonOutbound::ResolveAttach {
reply_to,
session_id,
} => {
registry
.resolve_attach(daemon_id, reply_to, session_id, shared_secret)
.await;
}
DaemonOutbound::ForwardCreate { reply_to, request } => {
// Relay the create to its owning daemon and route the reply back to
// this (home) daemon — control-plane only (NAP-327).
registry.forward_create(daemon_id, reply_to, request).await;
}
DaemonOutbound::ForwardKill {
reply_to,
daemon_id: target,
id,
} => {
registry
.forward_kill(daemon_id, reply_to, &target, id)
.await;
}
DaemonOutbound::ListDirResult { reply_to, result } => {
registry.complete_list_dir(reply_to, result).await;
}
DaemonOutbound::ForwardListDir {
reply_to,
daemon_id: target,
path,
} => {
// Relay a directory listing to its owning daemon and route the reply
// back to this (home) daemon — control-plane only (NAP-329).
registry
.forward_list_dir(daemon_id, reply_to, &target, path)
.await;
}
DaemonOutbound::RecentDirsResult { reply_to, result } => {
registry.complete_recent_dirs(reply_to, result).await;
}
DaemonOutbound::ForwardRecentDirs {
reply_to,
daemon_id: target,
} => {
// Relay a recent-dirs query to its owning daemon and route the reply
// back to this (home) daemon — control-plane only (#26).
registry
.forward_recent_dirs(daemon_id, reply_to, &target)
.await;
}
DaemonOutbound::GitRootResult { reply_to, result } => {
registry.complete_git_root(reply_to, result).await;
}
DaemonOutbound::ForwardGitRoot {
reply_to,
daemon_id: target,
path,
} => {
// Relay a gitroot probe to its owning daemon and route the reply
// back to this (home) daemon — control-plane only (#26).
registry
.forward_git_root(daemon_id, reply_to, &target, path)
.await;
}
DaemonOutbound::RelayAck { reply_to, .. } => {
// T3 (relay) is gated on NAP-238. Land the variant so newer daemons
// don't crash the connection; the registry doesn't act on it yet.
tracing::debug!(%daemon_id, ?reply_to, "RelayAck received but T3 not implemented (NAP-238 pending)");
}
DaemonOutbound::Unknown => {
// A newer daemon emitted a frame type this build doesn't know. Skip
// it rather than dropping the connection (NAP-269).
tracing::debug!(%daemon_id, "unknown frame type — skipping (forward-compat, NAP-269)");
}
}
true
}
async fn send_error(
sink: &mut futures_util::stream::SplitSink<WebSocket, Message>,
code: &str,
message: &str,
) -> Result<(), axum::Error> {
let err = DaemonInbound::Error(ErrorInfo {
code: code.into(),
message: message.into(),
});
let text = serde_json::to_string(&err).expect("serialize error");
sink.send(Message::Text(text)).await
}
#[cfg(test)]
mod tests {
use super::*;
// `dispatch_text` returning `false` is what breaks the handler loop and
// triggers a deregister (catalog wipe). A frame the registry can't act on
// must NOT do that, or a newer daemon emitting an unknown variant gets
// stuck in a tight reconnect loop (NAP-269).
#[tokio::test]
async fn unknown_frame_keeps_connection_alive() {
let registry = Registry::new();
let frame = serde_json::json!({ "type": "FutureVariant", "x": 1 }).to_string();
assert!(
dispatch_text(&registry, "laptop", "tok", &frame).await,
"unknown frame type must be skipped, not drop the WS"
);
}
#[tokio::test]
async fn malformed_frame_keeps_connection_alive() {
let registry = Registry::new();
assert!(
dispatch_text(&registry, "laptop", "tok", "{ not valid json").await,
"malformed frame must be skipped, not drop the WS"
);
}
}
-102
View File
@@ -1,102 +0,0 @@
//! Registry-side JWT issuance for the per-session attach endpoint.
//!
//! The daemon validates these tokens locally with the same shared secret —
//! the registry doesn't see any callback on the attach hot path.
//! Spec §Auth, §"Wire protocol (Client↔Daemon)".
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use maestro_protocol::{JwtClaims, PairAttachClaims};
use uuid::Uuid;
/// Spec §Auth: ~5 minutes.
pub const ATTACH_JWT_TTL: Duration = Duration::minutes(5);
/// Pair-scoped attach JWT TTL — short-lived per the connection-orchestration
/// spec (60 s default; ample window for the home daemon to dial T2).
pub const PAIR_ATTACH_JWT_TTL: Duration = Duration::seconds(60);
/// `iss` claim baked into every registry-issued pair JWT. Canonical value
/// lives in `maestro-protocol` so issuers and verifiers can't drift (NAP-266).
pub use maestro_protocol::REGISTRY_ISSUER;
/// Mint a short-lived attach JWT for `(daemon_id, session_id)`.
pub fn issue_attach_jwt(
secret: &str,
daemon_id: &str,
session_id: Uuid,
) -> Result<String, jsonwebtoken::errors::Error> {
let now = Utc::now();
let claims = JwtClaims {
session_id,
daemon_id: daemon_id.to_string(),
exp: (now + ATTACH_JWT_TTL).timestamp(),
iat: Some(now.timestamp()),
};
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, &claims, &key)
}
/// Mint a pair-scoped attach JWT used by the home-daemon broker (NAP-247).
/// `sub` is the home daemon-id; `aud` is the target daemon-id. Target
/// validates signature, `iss == REGISTRY_ISSUER`, `aud == self.daemon_id`,
/// `sid == session`, and `exp > now` before accepting the broker-attach.
/// `sub` is no longer gated against an allow-list (NAP-339) — it's retained
/// for logging/tracing.
pub fn issue_pair_attach_jwt(
secret: &str,
home_daemon_id: &str,
target_daemon_id: &str,
session_id: Uuid,
) -> Result<String, jsonwebtoken::errors::Error> {
let now = Utc::now();
let claims = PairAttachClaims {
iss: REGISTRY_ISSUER.into(),
sub: home_daemon_id.to_string(),
aud: target_daemon_id.to_string(),
sid: session_id,
exp: (now + PAIR_ATTACH_JWT_TTL).timestamp(),
};
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, &claims, &key)
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{decode, DecodingKey, Validation};
#[test]
fn issued_token_verifies_with_shared_secret() {
let secret = "shared-secret";
let daemon = "laptop";
let session = Uuid::new_v4();
let token = issue_attach_jwt(secret, daemon, session).unwrap();
let decoded = decode::<JwtClaims>(
&token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::new(Algorithm::HS256),
)
.unwrap();
assert_eq!(decoded.claims.daemon_id, daemon);
assert_eq!(decoded.claims.session_id, session);
assert!(decoded.claims.exp > Utc::now().timestamp());
}
#[test]
fn pair_jwt_carries_iss_sub_aud_sid() {
let secret = "shared-secret";
let session = Uuid::new_v4();
let token = issue_pair_attach_jwt(secret, "laptop", "konishi", session).unwrap();
let mut v = Validation::new(Algorithm::HS256);
v.set_audience(&["konishi"]);
let decoded =
decode::<PairAttachClaims>(&token, &DecodingKey::from_secret(secret.as_bytes()), &v)
.unwrap();
assert_eq!(decoded.claims.iss, REGISTRY_ISSUER);
assert_eq!(decoded.claims.sub, "laptop");
assert_eq!(decoded.claims.aud, "konishi");
assert_eq!(decoded.claims.sid, session);
assert!(decoded.claims.exp > Utc::now().timestamp());
}
}
-34
View File
@@ -1,34 +0,0 @@
//! The always-on Maestro registry.
//!
//! Covers two surfaces:
//!
//! - Daemon-facing `WS /daemon` (NAP-213): presence + session catalog.
//! - Client-facing HTTP/WS API (NAP-215): list daemons/sessions, spawn/kill
//! sessions (forwarded to the owning daemon via WS), issue per-session
//! attach JWTs, and broadcast catalog deltas over `WS /api/events`.
//!
//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §Registry,
//! §"Wire protocol", and §Auth.
#![deny(missing_debug_implementations)]
pub mod auth;
pub mod client_api;
pub mod events;
pub mod handler;
pub mod jwt;
pub mod run;
pub mod server;
pub mod state;
pub mod token;
pub use run::run;
pub use auth::{issue_cookie, verify_cookie, COOKIE_NAME};
pub use jwt::{issue_attach_jwt, ATTACH_JWT_TTL};
pub use server::{router, router_full, router_with_timeout, RegistryServer};
pub use state::{DaemonView, Registry, RegistryError, RpcError, HEARTBEAT_TIMEOUT, RPC_TIMEOUT};
pub use token::{
default_config_path, ensure_token, load_config, resolve_config_path, RegistryConfig,
TokenError, CONFIG_PATH_ENV, ENV_REGISTRY_BIND_ADDR, ENV_TOKEN,
};
-51
View File
@@ -1,51 +0,0 @@
//! Public entry point: load config, bind, serve until SIGINT.
//!
//! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
//! binary can dispatch `maestro registry` here.
use std::error::Error;
use std::path::PathBuf;
use tokio::net::TcpListener;
use crate::{
default_config_path, load_config, router, Registry, ENV_REGISTRY_BIND_ADDR, ENV_TOKEN,
};
/// Run the registry. `config_override` is the top-level `--config` flag from
/// the umbrella binary (also populated from `$MAESTRO_CONFIG` by clap).
pub async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let config_path: PathBuf = match config_override {
Some(p) => p,
None => default_config_path()?,
};
let env_token = std::env::var(ENV_TOKEN).ok();
let env_bind = std::env::var(ENV_REGISTRY_BIND_ADDR).ok();
let token_from_env = env_token.is_some();
let bind_from_env = env_bind.is_some();
let config = load_config(&config_path, env_token, env_bind.as_deref())?;
tracing::info!(
config = %config_path.display(),
bind = %config.bind_addr,
token_from_env,
bind_from_env,
"loaded config"
);
let listener = TcpListener::bind(config.bind_addr).await?;
let bound = listener.local_addr()?;
tracing::info!(addr = %bound, "registry listening");
let registry = Registry::new();
let app = router(registry, config.token);
let shutdown = async {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("ctrl-c received — shutting down");
};
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await?;
Ok(())
}
-110
View File
@@ -1,110 +0,0 @@
//! axum router + listener for the registry, including both the daemon-facing
//! `WS /daemon` endpoint (NAP-213) and the client-facing HTTP API (NAP-215).
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::WebSocketUpgrade;
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::any;
use axum::{middleware, Router};
use tokio::net::TcpListener;
use crate::auth::{cookie_middleware, AuthState};
use crate::client_api::{routes as client_routes, ClientApiState};
use crate::handler::handle_socket;
use crate::state::{Registry, HEARTBEAT_TIMEOUT, RPC_TIMEOUT};
#[derive(Debug, Clone)]
struct DaemonAppState {
registry: Registry,
token: Arc<String>,
heartbeat_timeout: Duration,
}
/// Build the production router (daemon WS + client HTTP API) with default
/// timeouts.
pub fn router(registry: Registry, token: String) -> Router {
router_full(registry, token, HEARTBEAT_TIMEOUT, RPC_TIMEOUT)
}
/// Variant for integration tests that want a short heartbeat. Keeps the
/// existing 4-arg call sites in `daemon_ws.rs` working.
pub fn router_with_timeout(
registry: Registry,
token: String,
heartbeat_timeout: Duration,
) -> Router {
router_full(registry, token, heartbeat_timeout, RPC_TIMEOUT)
}
/// Full builder; lets tests override every knob.
pub fn router_full(
registry: Registry,
token: String,
heartbeat_timeout: Duration,
rpc_timeout: Duration,
) -> Router {
let token_arc = Arc::new(token.clone());
let daemon_router = Router::new()
.route("/daemon", any(daemon_ws))
.with_state(DaemonAppState {
registry: registry.clone(),
token: token_arc.clone(),
heartbeat_timeout,
});
let client_router = client_routes(ClientApiState {
registry,
token: token_arc.clone(),
rpc_timeout,
})
.layer(middleware::from_fn_with_state(
AuthState {
token: token_arc.clone(),
},
cookie_middleware,
));
Router::new().merge(daemon_router).merge(client_router)
}
async fn daemon_ws(State(state): State<DaemonAppState>, ws: WebSocketUpgrade) -> impl IntoResponse {
let registry = state.registry.clone();
let token = state.token.clone();
let timeout = state.heartbeat_timeout;
ws.on_upgrade(move |socket| async move {
let _ = handle_socket(socket, registry, token.as_str(), timeout).await;
})
}
/// Convenience: bind a TCP listener and serve the router on it.
#[derive(Debug)]
pub struct RegistryServer {
pub addr: SocketAddr,
pub registry: Registry,
}
impl RegistryServer {
/// Bind to `bind_addr` and serve in-place. Returns the bound `SocketAddr`
/// (so callers passing `:0` can discover it) and a handle to the registry
/// state. The future runs until the listener is dropped.
pub async fn bind(
bind_addr: SocketAddr,
token: String,
) -> std::io::Result<(Self, impl std::future::Future<Output = std::io::Result<()>>)> {
let listener = TcpListener::bind(bind_addr).await?;
let addr = listener.local_addr()?;
let registry = Registry::new();
let app = router(registry.clone(), token);
let server = Self {
addr,
registry: registry.clone(),
};
let fut = async move { axum::serve(listener, app).await };
Ok((server, fut))
}
}
File diff suppressed because it is too large Load Diff
-289
View File
@@ -1,289 +0,0 @@
//! Registry view over the unified Maestro config + first-run token bootstrap.
//!
//! On startup the registry looks for the unified file at
//! `~/.config/maestro/config.toml`:
//!
//! - If `[shared].token` is present, loads it.
//! - If absent, generates a 256-bit random token, prints it once to stderr,
//! and persists it back to the same file via read-modify-write (so any
//! `[cli]` / `[daemon]` sections from a co-located host stay intact).
//! Subsequent runs are silent.
//!
//! Env-var overrides (applied by [`load_config`]):
//!
//! - `MAESTRO_TOKEN` — when set, bypasses the file entirely (no read,
//! no write). Lets the binary run in a container with no writable volume.
//! - `MAESTRO_REGISTRY_BIND_ADDR` — when set, overrides `bind_addr`.
//!
//! Default config path: `~/.config/maestro/config.toml` (override via
//! `$MAESTRO_CONFIG`).
use std::net::SocketAddr;
use std::path::Path;
use maestro_config::MaestroConfigFile;
use rand::RngCore;
pub use maestro_config::{default_config_path, resolve_config_path, CONFIG_PATH_ENV};
/// Default `bind_addr` when neither file nor env specify one.
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
/// Env var that overrides `[shared].token`.
pub const ENV_TOKEN: &str = "MAESTRO_TOKEN";
/// Env var that overrides `[registry].bind_addr`.
pub const ENV_REGISTRY_BIND_ADDR: &str = "MAESTRO_REGISTRY_BIND_ADDR";
fn default_bind_addr() -> SocketAddr {
DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr")
}
#[derive(Debug, thiserror::Error)]
pub enum TokenError {
#[error(transparent)]
Inner(#[from] maestro_config::ConfigError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
/// Resolved registry config: the token to sign JWTs with and the address
/// to bind on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryConfig {
pub token: String,
pub bind_addr: SocketAddr,
}
/// Load `[shared].token` (or generate one and persist it). Returns the
/// resolved [`RegistryConfig`] populated with `[registry].bind_addr` when
/// present, else the default.
///
/// On first run (no `[shared].token` in the file) this prints the freshly
/// generated token to stderr so the operator can copy it to every daemon.
pub fn ensure_token(config_path: &Path) -> Result<RegistryConfig, TokenError> {
let mut file = MaestroConfigFile::load_or_default(config_path)?;
let bind_addr = file.registry.bind_addr.unwrap_or_else(default_bind_addr);
if let Some(token) = file.shared.token.clone() {
return Ok(RegistryConfig { token, bind_addr });
}
let token = generate_token();
file.shared.token = Some(token.clone());
file.save(config_path)?;
eprintln!(
"maestro-registry: generated new MAESTRO_TOKEN; deploy this to every daemon:\n {token}"
);
Ok(RegistryConfig { token, bind_addr })
}
/// Resolve the runtime config, applying env-var overrides on top of (or
/// instead of) the on-disk config file.
///
/// Precedence per field: env > file > default.
///
/// `env_token` (`MAESTRO_TOKEN`): when `Some`, the file is neither
/// read nor created — useful in container deployments without a writable
/// volume.
/// `env_bind_addr` (`MAESTRO_REGISTRY_BIND_ADDR`): when `Some`, overrides
/// the bind address.
///
/// Pass real env values from `main`; tests pass explicit `Option`s.
pub fn load_config(
config_path: &Path,
env_token: Option<String>,
env_bind_addr: Option<&str>,
) -> Result<RegistryConfig, TokenError> {
let mut cfg = match env_token {
Some(token) => RegistryConfig {
token,
bind_addr: default_bind_addr(),
},
None => ensure_token(config_path)?,
};
if let Some(raw) = env_bind_addr {
cfg.bind_addr = maestro_config::parse_bind_addr(raw, ENV_REGISTRY_BIND_ADDR)?;
}
Ok(cfg)
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
hex_encode(&bytes)
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0xf) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_run_generates_persistent_token_in_shared_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = ensure_token(&path).unwrap();
assert_eq!(cfg.token.len(), 64); // 32 bytes hex-encoded
assert_eq!(cfg.bind_addr, default_bind_addr());
let again = ensure_token(&path).unwrap();
assert_eq!(cfg, again, "second call must return same token");
let file = MaestroConfigFile::load(&path).unwrap();
assert_eq!(file.shared.token.as_deref(), Some(cfg.token.as_str()));
}
#[test]
fn first_run_preserves_other_sections() {
// A host running both daemon and registry already has the daemon's
// config in the file. Registry's first-run token gen must not lose
// [daemon] or [shared].registry_url.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
registry_url = "https://r.example"
[daemon]
daemon_url = "ws://laptop:7070"
"#,
)
.unwrap();
let cfg = ensure_token(&path).unwrap();
assert_eq!(cfg.token.len(), 64);
let file = MaestroConfigFile::load(&path).unwrap();
assert_eq!(
file.shared.registry_url.as_deref(),
Some("https://r.example")
);
assert_eq!(file.shared.token.as_deref(), Some(cfg.token.as_str()));
assert_eq!(file.daemon.daemon_url.as_deref(), Some("ws://laptop:7070"));
}
#[test]
fn creates_parent_directory() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested/here/config.toml");
let cfg = ensure_token(&path).unwrap();
assert!(path.exists());
assert_eq!(cfg.token.len(), 64);
}
#[test]
fn existing_token_is_loaded_as_is() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
token = "abcd"
"#,
)
.unwrap();
let cfg = ensure_token(&path).unwrap();
assert_eq!(cfg.token, "abcd");
assert_eq!(cfg.bind_addr, default_bind_addr());
}
#[test]
fn custom_bind_addr_is_preserved() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
token = "x"
[registry]
bind_addr = "0.0.0.0:9999"
"#,
)
.unwrap();
let cfg = ensure_token(&path).unwrap();
assert_eq!(cfg.bind_addr, "0.0.0.0:9999".parse().unwrap());
}
#[test]
fn env_token_bypasses_file_entirely() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = load_config(&path, Some("envtoken".into()), None).unwrap();
assert_eq!(cfg.token, "envtoken");
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(
!path.exists(),
"no file should be created when env token is set"
);
}
#[test]
fn env_bind_addr_overrides_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
token = "file"
[registry]
bind_addr = "127.0.0.1:1"
"#,
)
.unwrap();
let cfg = load_config(&path, None, Some("0.0.0.0:9090")).unwrap();
assert_eq!(cfg.token, "file");
assert_eq!(cfg.bind_addr, "0.0.0.0:9090".parse().unwrap());
}
#[test]
fn env_token_and_env_bind_addr_combined() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = load_config(&path, Some("t".into()), Some("0.0.0.0:7777")).unwrap();
assert_eq!(cfg.token, "t");
assert_eq!(cfg.bind_addr, "0.0.0.0:7777".parse().unwrap());
assert!(!path.exists());
}
#[test]
fn invalid_env_bind_addr_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let err = load_config(&path, Some("t".into()), Some("not-an-addr")).unwrap_err();
match err {
TokenError::Inner(maestro_config::ConfigError::InvalidBindAddr {
input, env, ..
}) => {
assert_eq!(input, "not-an-addr");
assert_eq!(env, ENV_REGISTRY_BIND_ADDR);
}
other => panic!("expected InvalidBindAddr, got {other:?}"),
}
}
#[test]
fn no_env_falls_through_to_ensure_token() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = load_config(&path, None, None).unwrap();
assert_eq!(cfg.token.len(), 64);
assert!(path.exists());
}
}
-391
View File
@@ -1,391 +0,0 @@
//! End-to-end tests for the `WS /daemon` endpoint, driven by a real
//! `tokio-tungstenite` client against an axum server bound to `:0`.
use std::collections::HashMap;
use std::future::Future;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
CreateSessionRequest, DaemonInbound, DaemonOutbound, RpcResult, Session, SessionStatus,
};
use maestro_registry::{router_with_timeout, Registry};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use uuid::Uuid;
const TOKEN: &str = "test-token-please-allow";
const TEST_HEARTBEAT: Duration = Duration::from_millis(500);
struct Harness {
addr: SocketAddr,
registry: Registry,
_shutdown_tx: oneshot::Sender<()>,
}
async fn spawn_registry() -> Harness {
spawn_registry_with_timeout(TEST_HEARTBEAT).await
}
async fn spawn_registry_with_timeout(heartbeat: Duration) -> Harness {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let registry = Registry::new();
let app = router_with_timeout(registry.clone(), TOKEN.to_string(), heartbeat);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
Harness {
addr,
registry,
_shutdown_tx: shutdown_tx,
}
}
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
async fn connect(addr: SocketAddr) -> WsClient {
let url = format!("ws://{addr}/daemon");
let (ws, _) = connect_async(url).await.expect("ws connect");
ws
}
async fn send_json(ws: &mut WsClient, msg: &DaemonOutbound) {
let text = serde_json::to_string(msg).unwrap();
ws.send(WsMessage::Text(text)).await.unwrap();
}
async fn recv_inbound(ws: &mut WsClient) -> Option<DaemonInbound> {
while let Some(Ok(msg)) = ws.next().await {
if let WsMessage::Text(t) = msg {
let parsed: DaemonInbound = serde_json::from_str(&t).unwrap();
// NAP-246: skip catalog frames — these are informational and not
// what most tests are asserting on.
if matches!(parsed, DaemonInbound::CatalogEvent { .. }) {
continue;
}
return Some(parsed);
}
}
None
}
fn hello(token: &str) -> DaemonOutbound {
DaemonOutbound::Hello {
daemon_id: "laptop".into(),
hostname: "naps-laptop".into(),
version: "0.1.0".into(),
token: token.into(),
daemon_url: "ws://127.0.0.1:8081".into(),
home_dir: String::new(),
}
}
fn sample_session(id: Uuid, daemon_id: &str) -> Session {
Session {
id,
daemon_id: daemon_id.to_string(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
status: SessionStatus::Running,
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
async fn wait_for<F, Fut>(timeout: Duration, mut cond: F)
where
F: FnMut() -> Fut,
Fut: Future<Output = bool>,
{
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if cond().await {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out after {timeout:?} waiting for condition");
}
// --------------------------------------------------------------------------
// Tests
// --------------------------------------------------------------------------
#[tokio::test]
async fn rejects_bad_token() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello("wrong")).await;
let reply = recv_inbound(&mut ws).await.expect("error frame");
match reply {
DaemonInbound::Error(e) => assert_eq!(e.code, "auth_failed"),
other => panic!("expected Error, got {other:?}"),
}
let _ = ws.next().await;
assert!(h.registry.daemons().await.is_empty());
}
#[tokio::test]
async fn accepts_good_token_and_snapshot() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let ack = recv_inbound(&mut ws).await.expect("ack");
assert!(matches!(ack, DaemonInbound::Ack));
let sessions = vec![
sample_session(Uuid::new_v4(), "laptop"),
sample_session(Uuid::new_v4(), "laptop"),
];
send_json(
&mut ws,
&DaemonOutbound::SessionsSnapshot {
sessions: sessions.clone(),
},
)
.await;
let expected = sessions.len();
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == expected }
})
.await;
let daemons = h.registry.daemons().await;
assert_eq!(daemons.len(), 1);
assert_eq!(daemons[0].daemon_id, "laptop");
}
#[tokio::test]
async fn applies_individual_diffs() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws).await;
let s1 = sample_session(Uuid::new_v4(), "laptop");
let s2 = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws,
&DaemonOutbound::SessionAdded {
session: s1.clone(),
},
)
.await;
send_json(
&mut ws,
&DaemonOutbound::SessionAdded {
session: s2.clone(),
},
)
.await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == 2 }
})
.await;
let mut s1_updated = s1.clone();
s1_updated.command = "fish".into();
send_json(
&mut ws,
&DaemonOutbound::SessionUpdated {
session: s1_updated.clone(),
},
)
.await;
send_json(&mut ws, &DaemonOutbound::SessionRemoved { id: s2.id }).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move {
let s = r.sessions().await;
s.len() == 1 && s[0].command == "fish"
}
})
.await;
}
#[tokio::test]
async fn heartbeat_timeout_drops_daemon() {
let h = spawn_registry_with_timeout(Duration::from_millis(200)).await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws).await;
send_json(
&mut ws,
&DaemonOutbound::SessionAdded {
session: sample_session(Uuid::new_v4(), "laptop"),
},
)
.await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == 1 }
})
.await;
// Hold the WS open but never send another frame. The server's per-read
// timeout should fire after 200ms and drop the daemon.
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.daemons().await.is_empty() && r.sessions().await.is_empty() }
})
.await;
}
#[tokio::test]
async fn re_registration_replaces_catalog() {
let h = spawn_registry().await;
let mut ws1 = connect(h.addr).await;
send_json(&mut ws1, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws1).await;
let s_old = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws1,
&DaemonOutbound::SessionAdded {
session: s_old.clone(),
},
)
.await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == 1 }
})
.await;
drop(ws1);
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.daemons().await.is_empty() }
})
.await;
let mut ws2 = connect(h.addr).await;
send_json(&mut ws2, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws2).await;
let s_new = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws2,
&DaemonOutbound::SessionsSnapshot {
sessions: vec![s_new.clone()],
},
)
.await;
let registry = h.registry.clone();
let want_id = s_new.id;
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move {
let s = r.sessions().await;
s.len() == 1 && s[0].id == want_id
}
})
.await;
}
#[tokio::test]
async fn create_session_rpc_round_trip() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws).await;
let registry = h.registry.clone();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let rpc = tokio::spawn(async move { registry.create_session("laptop", request).await });
let inbound = recv_inbound(&mut ws).await.expect("CreateSession");
let (reply_to, _request) = match inbound {
DaemonInbound::CreateSession { reply_to, request } => (reply_to, request),
other => panic!("expected CreateSession, got {other:?}"),
};
let session = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws,
&DaemonOutbound::CreateSessionResult {
reply_to,
result: RpcResult::Ok {
value: session.clone(),
},
},
)
.await;
let outcome = rpc.await.unwrap().expect("rpc transport ok");
let returned = outcome.expect("rpc daemon ok");
assert_eq!(returned.id, session.id);
}
#[tokio::test]
async fn create_session_to_unknown_daemon_errors() {
let h = spawn_registry().await;
let err = h
.registry
.create_session(
"no-such-daemon",
CreateSessionRequest {
daemon_id: "no-such-daemon".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
},
)
.await
.unwrap_err();
assert!(matches!(
err,
maestro_registry::RegistryError::DaemonNotConnected(_)
));
}
-728
View File
@@ -1,728 +0,0 @@
//! Integration tests for the client-facing HTTP API (NAP-215).
//!
//! Spins up a registry on `:0`, plus a tiny mock-daemon WS client that exists
//! purely to satisfy `CreateSession` / `KillSession` round-trips. All
//! HTTP/WS calls go over the wire via `reqwest` and `tokio-tungstenite`.
use std::collections::HashMap;
use std::future::Future;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use maestro_protocol::{
ConnectInfo, CreateSessionRequest, DaemonInbound, DaemonInfo, DaemonOutbound, JwtClaims,
RegistryEvent, RpcResult, Session, SessionStatus,
};
use maestro_registry::{router_full, Registry, COOKIE_NAME};
use reqwest::Client;
use serde_json::Value;
use tokio::net::TcpListener;
use tokio::sync::{oneshot, Mutex};
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{
connect_async, tungstenite::client::IntoClientRequest, MaybeTlsStream, WebSocketStream,
};
use uuid::Uuid;
const TOKEN: &str = "http-api-test-token";
const TEST_HEARTBEAT: Duration = Duration::from_secs(30);
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
struct Harness {
addr: SocketAddr,
registry: Registry,
_shutdown_tx: oneshot::Sender<()>,
}
impl Harness {
fn base(&self) -> String {
format!("http://{}", self.addr)
}
fn ws_base(&self) -> String {
format!("ws://{}", self.addr)
}
}
async fn spawn(rpc_timeout: Duration) -> Harness {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let registry = Registry::new();
let app = router_full(
registry.clone(),
TOKEN.to_string(),
TEST_HEARTBEAT,
rpc_timeout,
);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
Harness {
addr,
registry,
_shutdown_tx: shutdown_tx,
}
}
// ---------------------------------------------------------------------------
// Mock daemon
// ---------------------------------------------------------------------------
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
#[derive(Clone, Copy)]
enum DaemonBehavior {
/// Replies immediately with the session the request asked for.
ReplyOk,
/// Never replies — exercises the registry's 504 timeout path.
Hang,
}
#[derive(Clone)]
struct MockDaemon {
daemon_id: String,
daemon_url: String,
captured: Arc<Mutex<Vec<DaemonInbound>>>,
}
impl MockDaemon {
fn new(daemon_id: &str, daemon_url: &str) -> Self {
Self {
daemon_id: daemon_id.into(),
daemon_url: daemon_url.into(),
captured: Arc::new(Mutex::new(Vec::new())),
}
}
async fn spawn(self, addr: SocketAddr, behavior: DaemonBehavior) {
let url = format!("ws://{addr}/daemon");
let (mut ws, _) = connect_async(url).await.expect("ws connect");
let hello = DaemonOutbound::Hello {
daemon_id: self.daemon_id.clone(),
hostname: "mock-host".into(),
version: "0.1.0".into(),
token: TOKEN.into(),
daemon_url: self.daemon_url.clone(),
home_dir: String::new(),
};
ws.send(WsMessage::Text(serde_json::to_string(&hello).unwrap()))
.await
.unwrap();
// consume the Ack
let _ = ws.next().await;
// send an empty initial snapshot so the registry's view is consistent
ws.send(WsMessage::Text(
serde_json::to_string(&DaemonOutbound::SessionsSnapshot { sessions: vec![] }).unwrap(),
))
.await
.unwrap();
let captured = self.captured.clone();
let daemon_id = self.daemon_id.clone();
tokio::spawn(async move {
run_loop(ws, behavior, captured, daemon_id).await;
});
}
async fn captured(&self) -> Vec<DaemonInbound> {
self.captured.lock().await.clone()
}
}
async fn run_loop(
mut ws: WsClient,
behavior: DaemonBehavior,
captured: Arc<Mutex<Vec<DaemonInbound>>>,
daemon_id: String,
) {
while let Some(Ok(WsMessage::Text(text))) = ws.next().await {
let msg: DaemonInbound = match serde_json::from_str(&text) {
Ok(m) => m,
Err(_) => continue,
};
captured.lock().await.push(msg.clone());
match (behavior, msg) {
(DaemonBehavior::ReplyOk, DaemonInbound::CreateSession { reply_to, request }) => {
let session = mock_session(&daemon_id, &request);
// Send SessionAdded BEFORE the RPC result so the registry's
// catalog is updated by the time the HTTP caller resumes.
// The registry processes inbound frames serially in order,
// so this ordering guarantee holds end-to-end.
let _ = ws
.send(WsMessage::Text(
serde_json::to_string(&DaemonOutbound::SessionAdded {
session: session.clone(),
})
.unwrap(),
))
.await;
let reply = DaemonOutbound::CreateSessionResult {
reply_to,
result: RpcResult::Ok { value: session },
};
let _ = ws
.send(WsMessage::Text(serde_json::to_string(&reply).unwrap()))
.await;
}
(DaemonBehavior::ReplyOk, DaemonInbound::KillSession { reply_to, id }) => {
let _ = ws
.send(WsMessage::Text(
serde_json::to_string(&DaemonOutbound::SessionRemoved { id }).unwrap(),
))
.await;
let reply = DaemonOutbound::KillSessionResult {
reply_to,
result: RpcResult::Ok { value: () },
};
let _ = ws
.send(WsMessage::Text(serde_json::to_string(&reply).unwrap()))
.await;
}
(DaemonBehavior::Hang, _) => {
// intentionally do nothing
}
_ => {}
}
}
}
fn mock_session(daemon_id: &str, request: &CreateSessionRequest) -> Session {
// If a worktree spec was attached, pretend the daemon "resolved" the cwd
// to <base_repo>/.worktrees/<branch> so the test can assert on it.
let cwd = request
.worktree
.as_ref()
.map(|w| w.base_repo.join(".worktrees").join(&w.branch))
.unwrap_or_else(|| request.cwd.clone());
Session {
id: Uuid::new_v4(),
daemon_id: daemon_id.into(),
cwd,
command: request.command.clone(),
env: request.env.clone(),
metadata: request.metadata.clone(),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async fn wait_for<F, Fut>(timeout: Duration, mut cond: F)
where
F: FnMut() -> Fut,
Fut: Future<Output = bool>,
{
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if cond().await {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out after {timeout:?} waiting for condition");
}
fn client_with_cookies() -> Client {
reqwest::Client::builder()
.cookie_store(true)
.build()
.unwrap()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn first_request_sets_cookie_and_subsequent_reuses_it() {
let h = spawn(Duration::from_secs(5)).await;
let client = client_with_cookies();
let r1 = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
assert!(r1.status().is_success());
let cookies: Vec<_> = r1
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.collect();
assert!(
!cookies.is_empty(),
"first request must include a Set-Cookie"
);
let raw = cookies[0].to_str().unwrap();
assert!(raw.contains(COOKIE_NAME));
assert!(raw.contains("HttpOnly"));
assert!(raw.contains("SameSite=Strict"));
let r2 = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
assert!(r2.status().is_success());
let new_cookies: Vec<_> = r2
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.collect();
assert!(
new_cookies.is_empty(),
"valid existing cookie should not be reissued, got {new_cookies:?}"
);
}
#[tokio::test]
async fn tampered_cookie_is_replaced_silently() {
let h = spawn(Duration::from_secs(5)).await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/daemons", h.base()))
.header(
reqwest::header::COOKIE,
format!("{}=not-a-jwt", COOKIE_NAME),
)
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let cookies: Vec<_> = resp
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.collect();
assert!(
!cookies.is_empty(),
"tampered cookie must be replaced with a freshly-signed one"
);
}
#[tokio::test]
async fn list_daemons_includes_url() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let resp = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let body: Vec<DaemonInfo> = resp.json().await.unwrap();
assert_eq!(body.len(), 1);
assert_eq!(body[0].daemon_id, "laptop");
assert_eq!(body[0].daemon_url, "ws://10.0.0.1:8081");
}
#[tokio::test]
async fn post_sessions_round_trips_to_daemon() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let resp = client
.post(format!("{}/api/sessions", h.base()))
.json(&request)
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 201);
let session: Session = resp.json().await.unwrap();
assert_eq!(session.daemon_id, "laptop");
assert_eq!(session.command, "bash");
// GET /api/sessions should now see it.
let resp = client
.get(format!("{}/api/sessions", h.base()))
.send()
.await
.unwrap();
let listed: Vec<Session> = resp.json().await.unwrap();
assert!(listed.iter().any(|s| s.id == session.id));
}
#[tokio::test]
async fn post_sessions_returns_504_on_daemon_timeout() {
// Short RPC timeout so the 504 path is fast.
let h = spawn(Duration::from_millis(200)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::Hang).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let resp = client
.post(format!("{}/api/sessions", h.base()))
.json(&request)
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 504);
// The daemon should have received the CreateSession even though it never
// replied.
let captured = mock.captured().await;
assert!(
captured
.iter()
.any(|m| matches!(m, DaemonInbound::CreateSession { .. })),
"daemon should have seen CreateSession, got {captured:?}"
);
}
#[tokio::test]
async fn post_sessions_with_worktree_forwards_spec_and_returns_resolved_cwd() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let body = serde_json::json!({
"daemon_id": "laptop",
"cwd": "/home/naps62/projects/maestro",
"command": "claude",
"worktree": {
"base_repo": "/home/naps62/projects/maestro",
"branch": "feat/test",
"cleanup_on_kill": true
}
});
let resp = client
.post(format!("{}/api/sessions", h.base()))
.json(&body)
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 201);
let session: Session = resp.json().await.unwrap();
assert_eq!(
session.cwd,
PathBuf::from("/home/naps62/projects/maestro/.worktrees/feat/test")
);
let captured = mock.captured().await;
let forwarded = captured
.iter()
.find_map(|m| match m {
DaemonInbound::CreateSession { request, .. } => Some(request.clone()),
_ => None,
})
.expect("CreateSession was not forwarded");
let spec = forwarded.worktree.expect("worktree spec dropped");
assert_eq!(spec.branch, "feat/test");
assert!(spec.cleanup_on_kill);
}
#[tokio::test]
async fn delete_session_round_trips() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let session: Session = client
.post(format!("{}/api/sessions", h.base()))
.json(&request)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
// The mock daemon doesn't actually update its catalog after kill — but the
// registry's catalog reflects whatever the daemon broadcasts. We're
// asserting only on the HTTP response.
let resp = client
.delete(format!("{}/api/sessions/{}", h.base(), session.id))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 204);
let captured = mock.captured().await;
assert!(
captured
.iter()
.any(|m| matches!(m, DaemonInbound::KillSession { id, .. } if *id == session.id)),
"daemon should have seen KillSession, got {captured:?}"
);
}
#[tokio::test]
async fn delete_session_returns_404_for_unknown_session() {
let h = spawn(Duration::from_secs(5)).await;
let client = client_with_cookies();
let resp = client
.delete(format!("{}/api/sessions/{}", h.base(), Uuid::new_v4()))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 404);
}
#[tokio::test]
async fn connect_info_issues_valid_jwt() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let session: Session = client
.post(format!("{}/api/sessions", h.base()))
.json(&CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let resp = client
.get(format!(
"{}/api/sessions/{}/connect-info",
h.base(),
session.id
))
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let info: ConnectInfo = resp.json().await.unwrap();
assert_eq!(info.daemon_url, "ws://10.0.0.1:8081");
// Verify the JWT against the same shared secret a daemon would use.
let decoded = decode::<JwtClaims>(
&info.jwt,
&DecodingKey::from_secret(TOKEN.as_bytes()),
&Validation::new(Algorithm::HS256),
)
.expect("jwt decodes");
assert_eq!(decoded.claims.daemon_id, "laptop");
assert_eq!(decoded.claims.session_id, session.id);
assert!(decoded.claims.exp > Utc::now().timestamp());
}
#[tokio::test]
async fn events_ws_pushes_snapshot_then_deltas() {
let h = spawn(Duration::from_secs(5)).await;
// First obtain a valid cookie via a plain HTTP call.
let client = client_with_cookies();
let _ = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
let cookie_value = harvest_cookie(&client, &h.base()).await;
// Now open the events WS with that cookie.
let url = format!("{}/api/events", h.ws_base());
let mut req = url.into_client_request().unwrap();
req.headers_mut().insert(
reqwest::header::COOKIE,
format!("{}={}", COOKIE_NAME, cookie_value).parse().unwrap(),
);
let (mut ws, _) = connect_async(req).await.expect("connect events ws");
let snapshot = recv_event(&mut ws).await;
match snapshot {
RegistryEvent::Snapshot { daemons, sessions } => {
assert!(daemons.is_empty());
assert!(sessions.is_empty());
}
other => panic!("expected Snapshot first, got {other:?}"),
}
// Now connect a daemon — subscriber should see DaemonAdded.
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let event = recv_event(&mut ws).await;
match event {
RegistryEvent::DaemonAdded { daemon } => assert_eq!(daemon.daemon_id, "laptop"),
other => panic!("expected DaemonAdded, got {other:?}"),
}
// Spawn a session via HTTP; subscriber should see SessionAdded.
let _: Session = client
.post(format!("{}/api/sessions", h.base()))
.json(&CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let event = recv_event(&mut ws).await;
assert!(
matches!(event, RegistryEvent::SessionAdded { .. }),
"expected SessionAdded, got {event:?}"
);
}
#[tokio::test]
async fn root_serves_no_ui() {
// The registry serves no web UI — that's `maestro-web`'s job (see
// connection-orchestration spec §"UI hosting"). `/` has no route.
let h = spawn(Duration::from_secs(5)).await;
let client = client_with_cookies();
let resp = client.get(h.base()).send().await.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
async fn harvest_cookie(client: &Client, base: &str) -> String {
// Reqwest's cookie store keeps the cookie, but doesn't expose values
// directly. Easiest: re-issue a request without the store and read the
// Set-Cookie that comes back.
let bare = reqwest::Client::new();
let resp = bare
.get(format!("{base}/api/daemons"))
.send()
.await
.unwrap();
let raw = resp
.headers()
.get(reqwest::header::SET_COOKIE)
.expect("set-cookie")
.to_str()
.unwrap();
// raw looks like "maestro_session=<value>; HttpOnly; ..."
let prefix = format!("{COOKIE_NAME}=");
let body = raw.strip_prefix(&prefix).expect("cookie prefix");
let value = body.split(';').next().unwrap();
let _ = client; // unused — kept in signature for symmetry
value.to_string()
}
async fn recv_event(ws: &mut WsClient) -> RegistryEvent {
let frame = tokio::time::timeout(Duration::from_secs(2), ws.next())
.await
.expect("event arrived")
.expect("ws open")
.expect("ws frame");
match frame {
WsMessage::Text(t) => match serde_json::from_str::<RegistryEvent>(&t) {
Ok(e) => e,
Err(e) => {
let parsed: Value = serde_json::from_str(&t).unwrap();
panic!("could not parse event {parsed:?}: {e}");
}
},
other => panic!("expected text frame, got {other:?}"),
}
}
+7 -49
View File
@@ -215,12 +215,7 @@ impl App {
self.state.overlay = Some(OverlayKind::Help);
}
Action::OpenSpawnModal => {
// Rebuild spawn modal against the current catalog so the
// daemon dropdown is fresh.
let snap = self.catalog.snapshot();
self.spawn = SpawnModal {
daemons: snap.daemons,
daemon_idx: 0,
cwd: String::new(),
command: String::new(),
focused_field: Default::default(),
@@ -291,16 +286,8 @@ impl App {
}
async fn submit_spawn(&mut self) {
let daemon_id = match self.spawn.daemons.get(self.spawn.daemon_idx) {
Some(d) => d.daemon_id.clone(),
None => {
self.state.overlay = Some(OverlayKind::Toast("spawn: pick a daemon first".into()));
return;
}
};
let cwd = std::path::PathBuf::from(self.spawn.cwd.clone());
let req = CreateSessionRequest {
daemon_id,
cwd,
command: self.spawn.command.clone(),
env: Default::default(),
@@ -348,7 +335,6 @@ impl App {
let snap = self.catalog.snapshot();
let attached: HashSet<Uuid> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
let sidebar = Sidebar {
daemons: snap.daemons.clone(),
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight,
attached,
@@ -412,17 +398,11 @@ impl App {
}
}
/// Order the sidebar walks for `j`/`k`: each daemon's sessions, in the same
/// order [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight
/// movement in visual sync with what the user sees.
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
/// visual sync with what the user sees.
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
let mut out = Vec::with_capacity(snap.sessions.len());
for d in &snap.daemons {
for s in snap.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
out.push(s.id);
}
}
out
snap.sessions.iter().map(|s| s.id).collect()
}
fn ws_base(http: &str) -> String {
@@ -498,43 +478,21 @@ mod tests {
}
#[tokio::test]
async fn open_spawn_modal_seeds_daemon_list() {
use maestro_protocol::{DaemonInfo, RegistryEvent};
async fn open_spawn_modal_sets_overlay() {
let mut app = make_app();
let d = DaemonInfo {
daemon_id: "konishi".into(),
hostname: "konishi.lan".into(),
version: "test".into(),
daemon_url: "ws://k:7070".into(),
home_dir: String::new(),
};
app.catalog.apply_event(RegistryEvent::Snapshot {
daemons: vec![d.clone()],
sessions: vec![],
});
app.dispatch(Action::OpenSpawnModal).await;
assert!(matches!(app.state.overlay, Some(OverlayKind::Spawn)));
assert_eq!(app.spawn.daemons.len(), 1);
assert_eq!(app.spawn.daemons[0].daemon_id, "konishi");
}
#[tokio::test]
async fn kill_highlighted_opens_confirm() {
use chrono::Utc;
use maestro_protocol::{DaemonInfo, RegistryEvent, Session, SessionStatus};
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let mut app = make_app();
let sid = Uuid::from_u128(7);
app.catalog.apply_event(RegistryEvent::Snapshot {
daemons: vec![DaemonInfo {
daemon_id: "k".into(),
hostname: "k".into(),
version: "t".into(),
daemon_url: "ws://k".into(),
home_dir: String::new(),
}],
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
id: sid,
daemon_id: "k".into(),
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
+30 -118
View File
@@ -1,10 +1,10 @@
//! `CatalogStore` — the TUI's view of the daemons-and-sessions tree.
//! `CatalogStore` — the TUI's view of the session list.
//!
//! Backed entirely by the daemon's `WS /api/events` stream:
//!
//! 1. The first frame after connect is a [`RegistryEvent::Snapshot`] — that's
//! 1. The first frame after connect is a [`CatalogEvent::Snapshot`] — that's
//! the bootstrap. No separate REST round-trip needed.
//! 2. Subsequent frames are deltas (`DaemonAdded`, `SessionAdded`, …) that we
//! 2. Subsequent frames are deltas (`SessionAdded`, `SessionUpdated`, …) that we
//! apply atomically and bump a monotonic version counter.
//! 3. On WS disconnect: exponential backoff (1s → 30s, ±25% jitter); the next
//! connect resends a fresh `Snapshot` so we converge without needing
@@ -21,7 +21,7 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
use futures_util::StreamExt;
use maestro_protocol::{DaemonInfo, RegistryEvent, Session};
use maestro_protocol::{CatalogEvent, Session};
use tokio::sync::Notify;
use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
@@ -31,7 +31,6 @@ use uuid::Uuid;
/// needed.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CatalogSnapshot {
pub daemons: Vec<DaemonInfo>,
pub sessions: Vec<Session>,
pub version: u64,
}
@@ -49,7 +48,6 @@ struct Inner {
#[derive(Debug, Default)]
struct State {
daemons: HashMap<String, DaemonInfo>,
sessions: HashMap<Uuid, Session>,
/// Bumped on every applied event. Wraps at u64::MAX, which is fine
/// because comparisons are equality, not ordering.
@@ -61,33 +59,20 @@ struct State {
impl CatalogStore {
/// Apply one event atomically. Used by tests directly and by the WS loop.
pub fn apply_event(&self, ev: RegistryEvent) {
pub fn apply_event(&self, ev: CatalogEvent) {
let mut s = self.inner.state.write().expect("catalog rwlock poisoned");
match ev {
RegistryEvent::Snapshot { daemons, sessions } => {
s.daemons = daemons
.into_iter()
.map(|d| (d.daemon_id.clone(), d))
.collect();
CatalogEvent::Snapshot { sessions } => {
s.sessions = sessions.into_iter().map(|x| (x.id, x)).collect();
s.bootstrapped = true;
}
RegistryEvent::DaemonAdded { daemon } => {
s.daemons.insert(daemon.daemon_id.clone(), daemon);
}
RegistryEvent::DaemonRemoved { daemon_id } => {
s.daemons.remove(&daemon_id);
// Drop sessions owned by the gone daemon — server will
// re-add them if it reappears.
s.sessions.retain(|_, sess| sess.daemon_id != daemon_id);
}
RegistryEvent::SessionAdded { session } | RegistryEvent::SessionUpdated { session } => {
CatalogEvent::SessionAdded { session } | CatalogEvent::SessionUpdated { session } => {
s.sessions.insert(session.id, session);
}
RegistryEvent::SessionRemoved { daemon_id: _, id } => {
CatalogEvent::SessionRemoved { id } => {
s.sessions.remove(&id);
}
RegistryEvent::Lagged { skipped } => {
CatalogEvent::Lagged { skipped } => {
tracing::warn!(
skipped,
"catalog events stream lagged; resync via reconnect"
@@ -101,15 +86,12 @@ impl CatalogStore {
self.inner.notify.notify_waiters();
}
/// Cheap O(daemons + sessions) read of current state.
/// Cheap O(sessions) read of current state.
pub fn snapshot(&self) -> CatalogSnapshot {
let s = self.inner.state.read().expect("catalog rwlock poisoned");
let mut daemons: Vec<DaemonInfo> = s.daemons.values().cloned().collect();
daemons.sort_by(|a, b| a.daemon_id.cmp(&b.daemon_id));
let mut sessions: Vec<Session> = s.sessions.values().cloned().collect();
sessions.sort_by_key(|x| x.id);
CatalogSnapshot {
daemons,
sessions,
version: s.version,
}
@@ -141,7 +123,7 @@ impl CatalogStore {
}
}
/// Long-running task: connect to `ws_url`, parse `RegistryEvent` frames,
/// Long-running task: connect to `ws_url`, parse [`CatalogEvent`] frames,
/// apply them. Reconnects with exponential backoff on disconnect. Never
/// returns — spawn it on a Tokio task.
pub async fn run_events_loop(self, ws_url: String) {
@@ -154,7 +136,7 @@ impl CatalogStore {
while let Some(frame) = stream.next().await {
match frame {
Ok(Message::Text(text)) => {
match serde_json::from_str::<RegistryEvent>(&text) {
match serde_json::from_str::<CatalogEvent>(&text) {
Ok(ev) => self.apply_event(ev),
Err(e) => {
tracing::warn!(error = ?e, "catalog: bad event frame");
@@ -197,22 +179,11 @@ fn jitter(base: Duration) -> Duration {
mod tests {
use super::*;
use chrono::Utc;
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
use maestro_protocol::{Session, SessionStatus};
fn daemon(id: &str) -> DaemonInfo {
DaemonInfo {
daemon_id: id.into(),
hostname: format!("{id}.lan"),
version: "test".into(),
daemon_url: format!("ws://{id}.lan:7070"),
home_dir: String::new(),
}
}
fn session(id: u128, daemon_id: &str, command: &str) -> Session {
fn session(id: u128, command: &str) -> Session {
Session {
id: Uuid::from_u128(id),
daemon_id: daemon_id.into(),
command: command.into(),
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
@@ -222,7 +193,6 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
@@ -230,82 +200,39 @@ mod tests {
#[test]
fn snapshot_event_replaces_state() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("konishi")],
sessions: vec![session(1, "konishi", "a")],
store.apply_event(CatalogEvent::Snapshot {
sessions: vec![session(1, "a")],
});
let snap = store.snapshot();
assert_eq!(snap.daemons.len(), 1);
assert_eq!(snap.sessions.len(), 1);
assert_eq!(snap.version, 1);
assert!(store.is_bootstrapped());
// A second snapshot wipes out the old state.
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("yamato")],
sessions: vec![],
});
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
let snap = store.snapshot();
assert_eq!(snap.daemons.len(), 1);
assert_eq!(snap.daemons[0].daemon_id, "yamato");
assert!(snap.sessions.is_empty());
assert_eq!(snap.version, 2);
}
#[test]
fn daemon_added_then_removed() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![],
sessions: vec![],
});
store.apply_event(RegistryEvent::DaemonAdded {
daemon: daemon("konishi"),
});
assert_eq!(store.snapshot().daemons.len(), 1);
store.apply_event(RegistryEvent::DaemonRemoved {
daemon_id: "konishi".into(),
});
assert!(store.snapshot().daemons.is_empty());
}
#[test]
fn daemon_removed_evicts_its_sessions() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("konishi"), daemon("yamato")],
sessions: vec![session(1, "konishi", "a"), session(2, "yamato", "b")],
});
store.apply_event(RegistryEvent::DaemonRemoved {
daemon_id: "konishi".into(),
});
let snap = store.snapshot();
assert_eq!(snap.sessions.len(), 1);
assert_eq!(snap.sessions[0].daemon_id, "yamato");
}
#[test]
fn session_added_updated_removed() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("konishi")],
sessions: vec![],
});
store.apply_event(RegistryEvent::SessionAdded {
session: session(7, "konishi", "vim"),
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
store.apply_event(CatalogEvent::SessionAdded {
session: session(7, "vim"),
});
assert_eq!(store.snapshot().sessions.len(), 1);
assert_eq!(store.snapshot().sessions[0].command, "vim");
let mut updated = session(7, "konishi", "claude");
let mut updated = session(7, "claude");
updated.status = SessionStatus::Dead;
store.apply_event(RegistryEvent::SessionUpdated { session: updated });
store.apply_event(CatalogEvent::SessionUpdated { session: updated });
let snap = store.snapshot();
assert_eq!(snap.sessions[0].command, "claude");
assert!(matches!(snap.sessions[0].status, SessionStatus::Dead));
store.apply_event(RegistryEvent::SessionRemoved {
daemon_id: "konishi".into(),
store.apply_event(CatalogEvent::SessionRemoved {
id: Uuid::from_u128(7),
});
assert!(store.snapshot().sessions.is_empty());
@@ -314,14 +241,12 @@ mod tests {
#[test]
fn lagged_event_doesnt_blow_up_state() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("konishi")],
sessions: vec![session(1, "konishi", "a")],
store.apply_event(CatalogEvent::Snapshot {
sessions: vec![session(1, "a")],
});
let before = store.snapshot();
store.apply_event(RegistryEvent::Lagged { skipped: 42 });
store.apply_event(CatalogEvent::Lagged { skipped: 42 });
let after = store.snapshot();
assert_eq!(after.daemons, before.daemons);
assert_eq!(after.sessions, before.sessions);
assert_eq!(after.version, before.version + 1);
}
@@ -329,17 +254,10 @@ mod tests {
#[test]
fn snapshot_is_sorted() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("yamato"), daemon("konishi"), daemon("aurora")],
sessions: vec![
session(3, "aurora", "c"),
session(1, "konishi", "a"),
session(2, "yamato", "b"),
],
store.apply_event(CatalogEvent::Snapshot {
sessions: vec![session(3, "c"), session(1, "a"), session(2, "b")],
});
let snap = store.snapshot();
let ids: Vec<_> = snap.daemons.iter().map(|d| d.daemon_id.as_str()).collect();
assert_eq!(ids, vec!["aurora", "konishi", "yamato"]);
let session_ids: Vec<_> = snap.sessions.iter().map(|s| s.id.as_u128()).collect();
assert_eq!(session_ids, vec![1, 2, 3]);
}
@@ -353,10 +271,7 @@ mod tests {
});
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(!handle.is_finished());
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![],
sessions: vec![],
});
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
// Give the waker a chance.
tokio::time::timeout(Duration::from_millis(200), handle)
.await
@@ -372,10 +287,7 @@ mod tests {
tokio::spawn(async move { s.changed().await })
};
tokio::time::sleep(Duration::from_millis(10)).await;
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![],
sessions: vec![],
});
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
tokio::time::timeout(Duration::from_millis(200), waiter)
.await
.expect("changed did not fire")
+3 -6
View File
@@ -1,7 +1,4 @@
//! `maestro tui` — terminal UI client over the local daemon's `/api/*` surface.
//!
//! Design: `docs/superpowers/specs/2026-05-28-maestro-tui-design.md`
//! Implementation: `docs/superpowers/plans/2026-05-28-maestro-tui.md`
#![deny(missing_debug_implementations)]
@@ -47,7 +44,7 @@ pub struct RunConfig {
#[derive(Debug, thiserror::Error)]
pub enum TuiError {
#[error("daemon unreachable at {0}; try `maestro daemon configure` or check `systemctl --user status maestro-daemon`")]
#[error("daemon unreachable at {0}; check `systemctl --user status maestro-daemon`")]
DaemonUnreachable(String),
#[error("config: {0}")]
Config(String),
@@ -74,7 +71,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
maestro_config::resolve_config_path().map_err(|e| TuiError::Config(e.to_string()))
});
let cfg_file = match cfg_path {
Ok(path) => maestro_config::MaestroConfigFile::load_or_default(&path)
Ok(path) => maestro_config::MaestroConfigFile::load(&path)
.map_err(|e| TuiError::Config(e.to_string()))?,
Err(_) => maestro_config::MaestroConfigFile::default(),
};
@@ -104,7 +101,7 @@ async fn health_check(base_url: &str) -> Result<(), TuiError> {
.timeout(Duration::from_millis(500))
.build()
.expect("reqwest client");
let url = format!("{base_url}/api/web/info");
let url = format!("{base_url}/api/info");
for _ in 0..3 {
if let Ok(res) = client.get(&url).send().await {
if res.status().is_success() {
+12 -52
View File
@@ -4,7 +4,6 @@
//! All overlays are centered popups drawn over the rest of the UI; the
//! `Clear` widget wipes the underlying buffer before drawing.
use maestro_protocol::DaemonInfo;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
@@ -14,7 +13,6 @@ use ratatui::Frame;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SpawnField {
#[default]
Daemon,
Cwd,
Command,
Submit,
@@ -22,8 +20,6 @@ pub enum SpawnField {
#[derive(Debug, Default, Clone)]
pub struct SpawnModal {
pub daemons: Vec<DaemonInfo>,
pub daemon_idx: usize,
pub cwd: String,
pub command: String,
pub focused_field: SpawnField,
@@ -32,17 +28,15 @@ pub struct SpawnModal {
impl SpawnModal {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Daemon => SpawnField::Cwd,
SpawnField::Cwd => SpawnField::Command,
SpawnField::Command => SpawnField::Submit,
SpawnField::Submit => SpawnField::Daemon,
SpawnField::Submit => SpawnField::Cwd,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Daemon => SpawnField::Submit,
SpawnField::Cwd => SpawnField::Daemon,
SpawnField::Cwd => SpawnField::Submit,
SpawnField::Command => SpawnField::Cwd,
SpawnField::Submit => SpawnField::Command,
};
@@ -70,20 +64,6 @@ impl SpawnModal {
match self.focused_field {
SpawnField::Cwd => self.cwd.push(*c),
SpawnField::Command => self.command.push(*c),
SpawnField::Daemon => {
if !self.daemons.is_empty() {
match c {
'j' => {
self.daemon_idx = (self.daemon_idx + 1) % self.daemons.len();
}
'k' => {
self.daemon_idx = (self.daemon_idx + self.daemons.len() - 1)
% self.daemons.len();
}
_ => {}
}
}
}
SpawnField::Submit => {}
}
SpawnInputResult::Consumed
@@ -105,13 +85,12 @@ impl SpawnModal {
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 60, 12);
let area = centered(parent, 60, 10);
f.render_widget(Clear, area);
let block = Block::default().title(" spawn ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
@@ -124,11 +103,6 @@ impl SpawnModal {
Style::default()
}
};
let daemon_label = self
.daemons
.get(self.daemon_idx)
.map(|d| d.hostname.as_str())
.unwrap_or("(none)");
let field_row = |label: &str, value: &str, style: Style| {
Paragraph::new(Line::from(vec![
ratatui::text::Span::raw(format!("{label:8} ")),
@@ -136,23 +110,19 @@ impl SpawnModal {
]))
};
f.render_widget(
field_row("daemon:", daemon_label, highlight(SpawnField::Daemon)),
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
rows[0],
);
f.render_widget(
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
rows[1],
);
f.render_widget(
field_row("command:", &self.command, highlight(SpawnField::Command)),
rows[2],
rows[1],
);
f.render_widget(
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
"[ Spawn ]",
highlight(SpawnField::Submit),
)])),
rows[3],
rows[2],
);
// Place the cursor at the end of the currently-focused text field so
@@ -165,12 +135,12 @@ impl SpawnModal {
.saturating_add(value.chars().count() as u16)
};
match self.focused_field {
SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[1].y)),
SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[0].y)),
SpawnField::Command => {
f.set_cursor_position((cursor_col_for(&self.command), rows[2].y))
f.set_cursor_position((cursor_col_for(&self.command), rows[1].y))
}
SpawnField::Daemon | SpawnField::Submit => {
// Non-text fields are indicated by the REVERSED style; no
SpawnField::Submit => {
// The submit button is indicated by the REVERSED style; no
// cursor placement.
}
}
@@ -293,20 +263,12 @@ mod tests {
#[test]
fn spawn_modal_renders_field_labels() {
let modal = SpawnModal {
daemons: vec![DaemonInfo {
daemon_id: "k".into(),
hostname: "konishi".into(),
version: "0".into(),
daemon_url: "ws://x".into(),
home_dir: String::new(),
}],
daemon_idx: 0,
cwd: "/x".into(),
command: "bash".into(),
focused_field: SpawnField::Cwd,
};
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
for s in ["daemon", "cwd", "command", "Spawn", "konishi", "/x", "bash"] {
for s in ["cwd", "command", "Spawn", "/x", "bash"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
@@ -314,15 +276,13 @@ mod tests {
#[test]
fn spawn_modal_field_rotation() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Daemon);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Command);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Daemon);
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.prev_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
}
+24 -51
View File
@@ -1,10 +1,10 @@
//! Sidebar widget — daemon→session tree, navigation-only.
//! Sidebar widget — session list, navigation-only.
//!
//! Status glyphs: ● attached / ○ live / ✗ dead.
use std::collections::HashSet;
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
use maestro_protocol::{Session, SessionStatus};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
@@ -15,7 +15,6 @@ use uuid::Uuid;
/// Renderable sidebar state.
#[derive(Debug, Clone)]
pub struct Sidebar {
pub daemons: Vec<DaemonInfo>,
pub sessions: Vec<Session>,
pub highlight: Option<Uuid>,
pub attached: HashSet<Uuid>,
@@ -43,28 +42,25 @@ impl Sidebar {
f.render_widget(block, area);
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
"daemons",
"sessions",
Style::default().add_modifier(Modifier::BOLD),
))];
for d in &self.daemons {
lines.push(Line::from(format!("● {}", d.hostname)));
for s in self.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
let label = name_for(s);
let style = if self.highlight == Some(s.id) {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{glyph} {label}"), style),
]));
}
for s in &self.sessions {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
let label = name_for(s);
let style = if self.highlight == Some(s.id) {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{glyph} {label}"), style),
]));
}
f.render_widget(Paragraph::new(lines), inner);
}
@@ -87,22 +83,11 @@ mod tests {
use ratatui::Terminal;
use std::collections::HashMap;
fn daemon(id: &str) -> DaemonInfo {
DaemonInfo {
daemon_id: id.into(),
hostname: id.into(),
version: "test".into(),
daemon_url: format!("ws://{id}:7070"),
home_dir: String::new(),
}
}
fn session_named(id: u128, daemon_id: &str, name: &str) -> Session {
fn session_named(id: u128, name: &str) -> Session {
let mut metadata = HashMap::new();
metadata.insert("name".into(), serde_json::Value::String(name.into()));
Session {
id: Uuid::from_u128(id),
daemon_id: daemon_id.into(),
command: "bash".into(),
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
@@ -132,21 +117,13 @@ mod tests {
}
#[test]
fn renders_hostname_and_session_names() {
fn renders_session_names() {
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![
session_named(1, "konishi", "claude"),
session_named(2, "konishi", "bash"),
],
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
};
let rendered = render_to_string(&sb, 28, 12);
assert!(
rendered.contains("konishi"),
"missing daemon name:\n{rendered}"
);
assert!(
rendered.contains("claude"),
"missing session name:\n{rendered}"
@@ -161,8 +138,7 @@ mod tests {
fn attached_session_uses_filled_glyph() {
let sid = Uuid::from_u128(1);
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![session_named(1, "konishi", "claude")],
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
};
@@ -180,10 +156,9 @@ mod tests {
#[test]
fn dead_session_uses_cross_glyph() {
let mut sess = session_named(1, "konishi", "claude");
let mut sess = session_named(1, "claude");
sess.status = SessionStatus::Dead;
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![sess],
highlight: None,
attached: Default::default(),
@@ -200,7 +175,6 @@ mod tests {
fn falls_back_to_command_when_name_metadata_is_absent() {
let s = Session {
id: Uuid::from_u128(1),
daemon_id: "konishi".into(),
command: "vim".into(),
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
@@ -214,7 +188,6 @@ mod tests {
current_cwd: None,
};
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![s],
highlight: None,
attached: Default::default(),
+6 -22
View File
@@ -9,24 +9,13 @@ use axum::response::Response;
use axum::routing::any;
use axum::Router;
use chrono::Utc;
use maestro_protocol::{DaemonInfo, RegistryEvent, Session, SessionStatus};
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
use maestro_tui::catalog::CatalogStore;
use uuid::Uuid;
fn daemon(id: &str) -> DaemonInfo {
DaemonInfo {
daemon_id: id.into(),
hostname: format!("{id}.lan"),
version: "test".into(),
daemon_url: format!("ws://{id}.lan:7070"),
home_dir: String::new(),
}
}
fn session(id: u128, daemon_id: &str) -> Session {
fn session(id: u128) -> Session {
Session {
id: Uuid::from_u128(id),
daemon_id: daemon_id.into(),
command: "bash".into(),
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
@@ -43,18 +32,15 @@ fn session(id: u128, daemon_id: &str) -> Session {
async fn events_handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(|mut socket| async move {
let snapshot = RegistryEvent::Snapshot {
daemons: vec![daemon("konishi")],
sessions: vec![],
};
let snapshot = CatalogEvent::Snapshot { sessions: vec![] };
let text = serde_json::to_string(&snapshot).unwrap();
if socket.send(Message::Text(text)).await.is_err() {
return;
}
// Small delay so the client has the snapshot applied before the delta.
tokio::time::sleep(Duration::from_millis(50)).await;
let added = RegistryEvent::SessionAdded {
session: session(1, "konishi"),
let added = CatalogEvent::SessionAdded {
session: session(1),
};
let text = serde_json::to_string(&added).unwrap();
let _ = socket.send(Message::Text(text)).await;
@@ -85,9 +71,7 @@ async fn run_events_loop_applies_snapshot_then_delta() {
.await
.expect("initial snapshot never arrived");
let snap = store.snapshot();
assert_eq!(snap.daemons.len(), 1);
assert_eq!(snap.daemons[0].daemon_id, "konishi");
assert!(store.snapshot().sessions.is_empty());
// Delta arrives shortly after. Poll for up to 500 ms.
for _ in 0..50 {
+1
View File
@@ -24,6 +24,7 @@ include_dir.workspace = true
maestro-config.workspace = true
mime_guess.workspace = true
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
+1 -1
View File
@@ -79,5 +79,5 @@ pnpm dev
```
Vite serves on `:5173` and proxies `/api/*` straight to `maestrod` at
`127.0.0.1:7070` (override with `MAESTRO_REGISTRY=...`). You don't need to
`127.0.0.1:7070` (override with `MAESTRO_DAEMON_URL=...`). You don't need to
rebuild the Rust binary while iterating on the frontend.
+1 -5
View File
@@ -59,11 +59,7 @@ impl EnvOverrides {
/// Resolve the runtime configuration. Missing-file is fine: every field has
/// a default and the binary is happy to run with no config.toml at all.
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<WebConfig, WebConfigError> {
let file = if path.exists() {
MaestroConfigFile::load(path)?
} else {
MaestroConfigFile::default()
};
let file = MaestroConfigFile::load(path)?;
resolve_with(file.web, env)
}
-3
View File
@@ -1,8 +1,5 @@
//! `maestro-web` — standalone HTTP server that hosts the Maestro browser UI
//! and reverse-proxies API calls to a co-located `maestrod`.
//!
//! See `docs/superpowers/specs/2026-05-26-connection-orchestration.md` §UI
//! hosting for the architectural decisions.
#![deny(missing_debug_implementations)]
+101 -2
View File
@@ -202,12 +202,24 @@ fn payload_too_large() -> Response {
/// Open an upstream WS connection at `daemon_ws_base + path?query` and pump
/// frames in both directions until either side closes. Used for
/// `/api/attach/:id` and `/api/events`.
pub async fn pump_ws(client: WebSocket, upstream_url: String) {
pub async fn pump_ws(mut client: WebSocket, upstream_url: String) {
let (upstream, _) = match tokio_tungstenite::connect_async(&upstream_url).await {
Ok(pair) => pair,
Err(e) => {
tracing::warn!(url = %upstream_url, error = %e, "proxy: upstream WS connect failed");
// Drop the client socket — there's nothing useful to send it.
// The daemon rejects an attach it can't serve (unknown session)
// with a structured HTTP error *before* the upgrade. We've already
// upgraded the browser socket, so a bare drop reaches it as an
// opaque 1006 close — the attach pane just flashes and vanishes with
// no reason. Relay the daemon's reason as an application close frame
// the UI can show (#39).
let (code, reason) = upstream_close_reason(&e);
let _ = client
.send(AxMessage::Close(Some(axum::extract::ws::CloseFrame {
code,
reason: reason.into(),
})))
.await;
return;
}
};
@@ -274,6 +286,48 @@ pub async fn pump_ws(client: WebSocket, upstream_url: String) {
}
}
/// Largest application close code we emit; the browser surfaces it as
/// `CloseEvent.code`. Codes in 4000-4999 are reserved for application use.
const CLOSE_UPSTREAM_REJECTED: u16 = 4502;
/// Turn a failed upstream WS handshake into a browser-facing close code +
/// reason. When the daemon rejected the upgrade with its structured JSON error
/// body (`{"error":{"code","message"}}` — see `local_api::error_response`),
/// surface that message; otherwise fall back to a generic gateway reason. The
/// reason rides a WS control frame, so it's truncated to the 123-byte limit.
fn upstream_close_reason(e: &tokio_tungstenite::tungstenite::Error) -> (u16, String) {
use tokio_tungstenite::tungstenite::Error as WsError;
let msg = match e {
WsError::Http(resp) => resp
.body()
.as_ref()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(b).ok())
.and_then(|v| {
v.get("error")
.and_then(|err| err.get("message"))
.and_then(|m| m.as_str())
.map(str::to_owned)
})
.unwrap_or_else(|| "the session's daemon rejected the attach".to_owned()),
_ => "could not reach the session's daemon".to_owned(),
};
(CLOSE_UPSTREAM_REJECTED, truncate_reason(&msg))
}
/// Clamp a close reason to the WS control-frame budget (125 bytes total, 2 for
/// the code → 123 for the reason), respecting UTF-8 char boundaries.
fn truncate_reason(s: &str) -> String {
const MAX: usize = 123;
if s.len() <= MAX {
return s.to_owned();
}
let mut end = MAX;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_owned()
}
/// Compose the upstream WS URL from the base + path + query of the inbound
/// request.
pub fn upstream_ws_url(state: &ProxyState, uri: &Uri) -> String {
@@ -336,4 +390,49 @@ mod tests {
let resp = proxy_http(State(state), req).await;
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
/// #39: a daemon rejection carrying the structured error body surfaces its
/// `message` as the close reason, under an application close code, so the
/// browser can show *why* the attach failed instead of an opaque 1006.
#[test]
fn upstream_close_reason_extracts_daemon_message() {
use tokio_tungstenite::tungstenite::{http, Error as WsError};
let body =
br#"{"error":{"code":"unknown_session","message":"no such session on this daemon"}}"#
.to_vec();
let resp = http::Response::builder()
.status(404)
.body(Some(body))
.unwrap();
let (code, reason) = upstream_close_reason(&WsError::Http(resp));
assert_eq!(code, CLOSE_UPSTREAM_REJECTED);
assert_eq!(reason, "no such session on this daemon");
}
/// A non-HTTP failure (e.g. TCP refused — peer down) still yields a usable
/// generic reason rather than a panic or empty string.
#[test]
fn upstream_close_reason_falls_back_for_non_http_errors() {
use tokio_tungstenite::tungstenite::Error as WsError;
let e = WsError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"nope",
));
let (code, reason) = upstream_close_reason(&e);
assert_eq!(code, CLOSE_UPSTREAM_REJECTED);
assert!(!reason.is_empty());
}
/// Close reasons ride a control frame capped at 123 bytes; an over-long
/// message is truncated on a char boundary (no panic on multi-byte chars).
#[test]
fn truncate_reason_respects_control_frame_budget() {
let ascii = "x".repeat(200);
assert_eq!(truncate_reason(&ascii).len(), 123);
let multibyte = "é".repeat(100); // 2 bytes each
let out = truncate_reason(&multibyte);
assert!(out.len() <= 123);
assert!(out.is_char_boundary(out.len()));
}
}
+3 -3
View File
@@ -20,8 +20,8 @@ async fn spawn_mock_daemon() -> (SocketAddr, oneshot::Sender<()>) {
async fn list_sessions() -> Json<serde_json::Value> {
Json(serde_json::json!([{"id": "session-1"}]))
}
async fn web_info() -> Json<serde_json::Value> {
Json(serde_json::json!({"daemon_id": "mock", "hostname": "mock-host"}))
async fn daemon_info() -> Json<serde_json::Value> {
Json(serde_json::json!({"hostname": "mock-host"}))
}
async fn echo_ws(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(echo_handler)
@@ -43,7 +43,7 @@ async fn spawn_mock_daemon() -> (SocketAddr, oneshot::Sender<()>) {
let app = Router::new()
.route("/api/sessions", get(list_sessions))
.route("/api/web/info", get(web_info))
.route("/api/info", get(daemon_info))
.route("/api/attach/:id", any(echo_ws));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
+2 -4
View File
@@ -5,17 +5,16 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Unified `maestro` binary: CLI + per-host daemon + always-on registry, feature-gated."
description = "Unified `maestro` binary: CLI + per-host daemon + TUI, feature-gated."
[[bin]]
name = "maestro"
path = "src/main.rs"
[features]
default = ["cli", "daemon", "registry", "tui"]
default = ["cli", "daemon", "tui"]
cli = ["dep:maestro-cli"]
daemon = ["dep:maestro-daemon"]
registry = ["dep:maestro-registry"]
tui = ["dep:maestro-tui"]
[dependencies]
@@ -24,5 +23,4 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
tracing-subscriber.workspace = true
maestro-cli = { path = "../maestro-cli", optional = true }
maestro-daemon = { path = "../maestro-daemon", optional = true }
maestro-registry = { path = "../maestro-registry", optional = true }
maestro-tui = { path = "../maestro-tui", optional = true }
+23 -61
View File
@@ -1,19 +1,13 @@
//! `maestro` — unified binary that hosts the CLI, the per-host daemon, and the
//! always-on registry behind feature-gated subcommands (NAP-245).
//! TUI behind feature-gated subcommands (NAP-245).
//!
//! ```text
//! cargo install --no-default-features --features registry maestro # registry server
//! cargo install --no-default-features --features daemon maestro # daemon-only host
//! cargo install maestro # default: cli + daemon + registry
//! cargo install --no-default-features --features daemon maestro # daemon-only host
//! cargo install maestro # default: cli + daemon + tui
//! ```
#[cfg(not(any(
feature = "cli",
feature = "daemon",
feature = "registry",
feature = "tui"
)))]
compile_error!("at least one of the `cli`, `daemon`, `registry`, `tui` features must be enabled");
#[cfg(not(any(feature = "cli", feature = "daemon", feature = "tui")))]
compile_error!("at least one of the `cli`, `daemon`, `tui` features must be enabled");
use std::path::PathBuf;
use std::process::ExitCode;
@@ -25,20 +19,15 @@ use tracing_subscriber::EnvFilter;
#[command(
name = "maestro",
version,
about = "Unified CLI / daemon / registry for the Maestro terminal-session orchestrator",
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator",
arg_required_else_help = true
)]
struct Cli {
/// Override the registry URL for this invocation (e.g. `https://maestro.example.com`).
/// Used by CLI subcommands; ignored by `daemon` / `registry`.
#[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")]
registry_url: Option<String>,
/// Override the home-daemon HTTP base the CLI brokers through (e.g.
/// `http://127.0.0.1:7070`). Defaults to `[cli].home_daemon_url`, then the
/// local `[daemon].bind_addr`, then `http://127.0.0.1:7070`. CLI only.
#[arg(long, global = true, env = "MAESTRO_HOME_DAEMON_URL")]
home_daemon_url: Option<String>,
/// Override the daemon HTTP base clients talk to (e.g.
/// `http://127.0.0.1:7070`). Defaults to `[cli].daemon_url`, then the local
/// `[daemon].bind_addr`, then `http://127.0.0.1:7070`.
#[arg(long, global = true, env = "MAESTRO_DAEMON_URL")]
daemon_url: Option<String>,
/// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`.
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
@@ -54,10 +43,10 @@ struct Cli {
#[derive(Debug, Subcommand)]
enum Command {
/// List all sessions, grouped by daemon.
/// List all sessions.
#[cfg(feature = "cli")]
List(maestro_cli::cli::ListArgs),
/// Spawn a new session on a given daemon.
/// Spawn a new session.
#[cfg(feature = "cli")]
Spawn(maestro_cli::cli::SpawnArgs),
/// Attach to a session over WebSocket. Bytes flow through the local TTY.
@@ -66,21 +55,13 @@ enum Command {
/// Kill a session by ID.
#[cfg(feature = "cli")]
Kill(maestro_cli::cli::KillArgs),
/// Set the default registry URL persisted in the CLI config.
#[cfg(feature = "cli")]
SetRegistry(maestro_cli::cli::SetRegistryArgs),
/// Per-host daemon: tmux wrapper + registry client.
/// Per-host daemon: tmux wrapper + session API.
#[cfg(feature = "daemon")]
Daemon {
#[command(subcommand)]
cmd: Option<maestro_daemon::cli::DaemonCmd>,
},
/// Always-on registry: presence index + session catalog.
#[cfg(feature = "registry")]
Registry,
/// Open the terminal UI.
#[cfg(feature = "tui")]
Tui(maestro_tui::cli::TuiArgs),
@@ -111,8 +92,7 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
cli_dispatch(
maestro_cli::cli::Command::List(args),
cli.config,
cli.registry_url,
cli.home_daemon_url,
cli.daemon_url.clone(),
)
.await?
}
@@ -121,8 +101,7 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
cli_dispatch(
maestro_cli::cli::Command::Spawn(args),
cli.config,
cli.registry_url,
cli.home_daemon_url,
cli.daemon_url.clone(),
)
.await?
}
@@ -131,8 +110,7 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
cli_dispatch(
maestro_cli::cli::Command::Attach(args),
cli.config,
cli.registry_url,
cli.home_daemon_url,
cli.daemon_url.clone(),
)
.await?
}
@@ -141,29 +119,17 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
cli_dispatch(
maestro_cli::cli::Command::Kill(args),
cli.config,
cli.registry_url,
cli.home_daemon_url,
)
.await?
}
#[cfg(feature = "cli")]
Command::SetRegistry(args) => {
cli_dispatch(
maestro_cli::cli::Command::SetRegistry(args),
cli.config,
cli.registry_url,
cli.home_daemon_url,
cli.daemon_url.clone(),
)
.await?
}
#[cfg(feature = "daemon")]
Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?,
#[cfg(feature = "registry")]
Command::Registry => maestro_registry::run(cli.config).await?,
#[cfg(feature = "tui")]
Command::Tui(args) => {
let daemon_url = args
.daemon_url
.or(cli.daemon_url)
.unwrap_or_else(|| "http://127.0.0.1:7070".to_string());
maestro_tui::run(maestro_tui::RunConfig {
daemon_url,
@@ -176,13 +142,13 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
}
#[cfg(feature = "cli")]
#[allow(clippy::result_large_err)]
async fn cli_dispatch(
command: maestro_cli::cli::Command,
config: Option<PathBuf>,
registry_url: Option<String>,
home_daemon_url: Option<String>,
daemon_url: Option<String>,
) -> Result<(), maestro_cli::CliError> {
maestro_cli::dispatch(command, config, registry_url, home_daemon_url).await
maestro_cli::dispatch(command, config, daemon_url).await
}
fn init_tracing(cli: &Cli) {
@@ -201,11 +167,7 @@ fn init_tracing(cli: &Cli) {
fn is_cli_subcommand(command: &Command) -> bool {
matches!(
command,
Command::List(_)
| Command::Spawn(_)
| Command::Attach(_)
| Command::Kill(_)
| Command::SetRegistry(_)
Command::List(_) | Command::Spawn(_) | Command::Attach(_) | Command::Kill(_)
)
}
+26 -42
View File
@@ -16,11 +16,9 @@ The bootstrap script builds `maestro-installer` into a temp directory, runs the
wizard, and cleans up — only the unified `maestro` binary ends up persisted in
`~/.cargo/bin`.
**Updating is the same command.** Re-running `./install.sh` detects an
existing `[daemon]` section in `~/.config/maestro/config.toml` and an
existing systemd unit, skips the configure prompt, refreshes the unit, and
runs `systemctl --user restart maestro` instead of `start`. No `--update`
flag needed. **The restart keeps your sessions:** the unit sets
**Updating is the same command.** Re-running `./install.sh` detects an existing
systemd unit, refreshes it, and runs `systemctl --user restart maestro` instead
of `start`. No `--update` flag needed. **The restart keeps your sessions:** the unit sets
`KillMode=process`, so systemd signals only the daemon — the tmux server (and
every session) survives, and the new daemon re-adopts them on start. A unit
written before this default lacks the line; re-run `./install.sh` to refresh it.
@@ -43,11 +41,9 @@ The wizard:
1. Runs `cargo install` for the unified `maestro` crate (pinned to the
committed `Cargo.lock` via `--locked`). The same binary hosts the CLI,
the per-host daemon (`maestro daemon`), and the always-on registry
(`maestro registry`), gated by Cargo features.
2. Optionally runs `maestro daemon configure` to populate `~/.config/maestro/config.toml`
(one shared TOML for the CLI, daemon, registry, and web).
3. On systemd hosts, optionally writes a user unit at
the per-host daemon (`maestro daemon`), and the TUI, gated by Cargo
features.
2. On systemd hosts, optionally writes a user unit at
`~/.config/systemd/user/maestro.service`, reloads systemd, and offers to
enable + start it.
@@ -55,15 +51,11 @@ The binary lands in `~/.cargo/bin/maestro` — make sure that's on your `$PATH`.
## Thin builds for single-role hosts
The default install pulls in everything (`cli` + `daemon` + `registry` + `tui`).
Single-role hosts can opt out of unused dependencies:
The default install pulls in everything (`cli` + `daemon` + `tui`). Single-role
hosts can opt out of unused dependencies:
```sh
# Registry server — no tmux/PTY/jwt-verifier code:
cargo install --git https://git.naps.pt/naps62/maestro \
--no-default-features --features registry --locked maestro
# Daemon-only host — no embedded web assets, no registry HTTP surface:
# Daemon-only host — no CLI or TUI:
cargo install --git https://git.naps.pt/naps62/maestro \
--no-default-features --features daemon --locked maestro
@@ -81,8 +73,7 @@ in the default feature set, so a vanilla `cargo install maestro` builds it.
Drop the `tui` feature to skip it on hosts that don't need the UI surface
(`--no-default-features --features cli,daemon`).
It talks only to the **local** `maestro daemon` over `http://127.0.0.1:7070`
by default; peer daemons reach you transparently via the catalog mirror.
It talks to the local `maestro daemon` over `http://127.0.0.1:7070` by default.
Override the daemon URL with `--daemon-url` or `$MAESTRO_DAEMON_URL`.
The `[tui]` section in `~/.config/maestro/config.toml` (or whatever path you
@@ -109,16 +100,16 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
# want extra Insert-mode bindings)
```
The full action vocabulary lives in
`docs/superpowers/specs/2026-05-28-maestro-tui-design.md` §3.8.
The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`.
## Adding the browser UI (optional)
`maestro-web` is a separate binary that serves the SPA bundle and reverse-
proxies API calls to a co-located `maestro daemon`.
**Interactive install (default path).** The wizard now offers it as an
optional step right after the daemon flow:
**Interactive install (default path).** The wizard offers it as an optional
step right after the daemon flow:
```text
Optional: install the browser UI (`maestro-web`)?
@@ -150,9 +141,11 @@ http://127.0.0.1:7070`. The `[web]` section of
`~/.config/maestro/config.toml` overrides both — see
`crates/maestro-web/README.md`.
**Trust model.** Anyone who can reach the bind address can use it. Pick
`127.0.0.1` for local-only, a WireGuard/LAN address for one-segment access,
never a public IP without adding auth first.
**Trust model.** There is no authentication layer: anyone who can reach the
bind address (of either the daemon or `maestro-web`) can drive every session on
the host. Pick `127.0.0.1` for local-only, a WireGuard/LAN address for
one-segment access, and never a public IP — the network perimeter is the only
boundary Maestro has.
Headless hosts (servers, dev VMs) skip the prompt with `--no-web`. Hosts
where you want browser access answer yes — both binaries end up in
@@ -168,10 +161,9 @@ where you want browser access answer yes — both binaries end up in
| `--no-locked` | Drop `--locked` from `cargo install` (resolves a fresh dep tree). |
| `--exec-start <path>` | Override the systemd unit's `ExecStart` (default `~/.cargo/bin/maestro daemon`). |
| `--no-systemd` | Skip the systemd step even if detected. |
| `--no-configure` | Skip the `maestro daemon configure` step. |
| `--no-web` | Skip the optional `maestro-web` install step. |
| `--web-exec-start <path>` | Override the web unit's `ExecStart` (default `~/.cargo/bin/maestro-web`). |
| `-y`, `--yes` | Non-interactive: install binaries + drop systemd unit + reload + enable, but **don't** auto-run `configure` or auto-start the unit (those need real config). Also skips the web step unless an existing `maestro-web.service` is being refreshed. |
| `-y`, `--yes` | Non-interactive: install binaries + drop systemd unit + reload + enable, but **don't** auto-start the unit. Also skips the web step unless an existing `maestro-web.service` is being refreshed. |
## Where tmux sockets land
@@ -190,17 +182,6 @@ Pick `/tmp` if the daemon must outlive your login session and you can't run
tmux sockets with it). The env var `MAESTRO_DAEMON_TMUX_TMPDIR` takes
precedence over the file.
## Peer attach trust model
There is no per-peer allow-list. The operator's network perimeter (VPN/firewall)
is the trust boundary for *which hosts can connect*; every daemon reachable on
the perimeter may broker-attach to any other. Attach is still authenticated: the
registry mints a short-lived, pair-scoped JWT bound to the target daemon and the
specific session (`iss`/`aud`/`sid`/`exp`), which the target verifies — so a
reachable host still can't attach to a session it wasn't handed, or replay a
token elsewhere. (NAP-339 dropped the earlier `[daemon.policy].allow_inbound`
gate; re-introducing a per-pair allow-list later is a verify-time change.)
## What the systemd unit looks like
The wizard writes a *user* unit (`systemctl --user`), since `maestro daemon`
@@ -246,13 +227,16 @@ script avoids that by installing to a scratch dir.
```sh
cargo install --git https://git.naps.pt/naps62/maestro --locked maestro
maestro daemon configure
maestro daemon # foreground; or wire your own service manager
```
Every setting has a default, so `~/.config/maestro/config.toml` is optional —
create it only to change the bind address, worktree root, spawn presets, or TUI
keymap.
## Non-Linux hosts
The wizard works on macOS for the `cargo install` + `maestro daemon configure`
parts. The systemd step is automatically skipped (probe of
The wizard works on macOS for the `cargo install` part. The systemd step is
automatically skipped (probe of
`/run/systemd/system`). launchd integration is not yet supported — start
`maestro daemon` from your shell or your own service manager.
File diff suppressed because it is too large Load Diff
@@ -1,403 +0,0 @@
# maestro-web Bundle Build Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make every compiled `maestro-web` binary embed a real SPA bundle by building the frontend in `build.rs`, so no install method ever serves the "bundle not built" placeholder.
**Architecture:** A new `crates/maestro-web/build.rs` runs `pnpm install --frozen-lockfile && pnpm build` in `web/` before `include_dir!` embeds `web/dist`; missing pnpm/node hard-fails the build. The installer wizard preflights the toolchain at its opt-in web step and prompts abort/skip when it's absent. CI gains the Node + pnpm toolchain.
**Tech Stack:** Rust (cargo build scripts, `std::process::Command`), pnpm 9.15.4 + Node, Gitea Actions CI.
**Spec:** `docs/superpowers/specs/2026-05-28-maestro-web-bundle-build.md`
---
## File Structure
- **Create** `crates/maestro-web/build.rs` — builds the SPA bundle and declares frontend rerun triggers. Sole responsibility: guarantee `web/dist` is fresh before compile.
- **Modify** `crates/maestro-installer/src/main.rs` — add a pure `classify_web_prereq` decision + `frontend_toolchain_present` probe, and wire the abort/skip prompt into `handle_web`.
- **Modify** `.gitea/workflows/ci.yml` — install Node + pnpm so the workspace compiles `maestro-web`.
No changes to `static_bundle.rs` (the placeholder stays as unreachable defense-in-depth).
---
## Task 1: build.rs builds the SPA bundle at compile time
**Files:**
- Create: `crates/maestro-web/build.rs`
- Test: `crates/maestro-web/tests/embedded_bundle.rs`
- [ ] **Step 1: Write the failing test**
Create `crates/maestro-web/tests/embedded_bundle.rs`. It asserts the embedded
index is the real SPA (which mounts `<div id="app">`), not the placeholder
(whose `<h1>` reads "maestro-web is running"). `serve_index` is public in
`maestro_web::static_bundle` via `lib.rs`.
```rust
//! The compiled binary must embed the real SPA bundle, never the placeholder.
//! `build.rs` guarantees `web/dist` is populated before `include_dir!` runs.
use axum::body::to_bytes;
use maestro_web::static_bundle::serve_index;
#[tokio::test]
async fn embedded_index_is_real_spa_not_placeholder() {
let resp = serve_index();
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains(r#"id="app""#),
"served index is missing the SPA mount point; got:\n{html}"
);
assert!(
!html.contains("maestro-web is running"),
"served index is the placeholder, not the built bundle"
);
}
```
`static_bundle` is already `pub mod` in `crates/maestro-web/src/lib.rs` and `serve_index` is public, so no `lib.rs` change is needed. The `axum` and `tokio` dev-deps are already in `Cargo.toml`.
- [ ] **Step 2: Run the test and verify it fails**
First force an empty bundle so the test has a true red (this machine already has a built `web/dist`):
```bash
rm -rf web/dist && mkdir -p web/dist && touch web/dist/.gitkeep
cargo test -p maestro-web --test embedded_bundle
```
Expected: FAIL — `serve_index()` returns the placeholder, so the `id="app"` assertion fails. (Without `build.rs` yet, nothing rebuilds the bundle.)
- [ ] **Step 3: Write the build script**
Create `crates/maestro-web/build.rs`:
```rust
//! Build the embedded SPA bundle before the crate compiles.
//!
//! `static_bundle.rs` embeds `web/dist` at compile time via `include_dir!`.
//! This script guarantees that directory holds a freshly built bundle by
//! running `pnpm install --frozen-lockfile && pnpm build` in `web/`. pnpm and
//! node are HARD requirements — a missing toolchain fails the build with an
//! actionable message rather than silently shipping the placeholder.
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"));
let web_dir = manifest_dir.join("../../web");
// Rebuild the bundle only when a frontend input changes — not on every
// unrelated Rust recompile.
for rel in ["src", "index.html", "package.json", "pnpm-lock.yaml", "vite.config.ts"] {
println!("cargo:rerun-if-changed={}", web_dir.join(rel).display());
}
require_tool("pnpm");
require_tool("node");
run(
Command::new("pnpm")
.args(["install", "--frozen-lockfile"])
.current_dir(&web_dir),
"pnpm install --frozen-lockfile",
);
run(
Command::new("pnpm")
.arg("build")
.current_dir(&web_dir),
"pnpm build",
);
}
fn require_tool(tool: &str) {
let ok = Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
panic!(
"maestro-web requires `{tool}` to build its embedded SPA bundle. \
Install pnpm + node (https://pnpm.io) or use a prebuilt binary."
);
}
}
fn run(cmd: &mut Command, label: &str) {
let status = cmd
.status()
.unwrap_or_else(|e| panic!("failed to spawn `{label}`: {e}"));
if !status.success() {
panic!("`{label}` failed with {status}");
}
}
```
- [ ] **Step 4: Run the test and verify it passes**
```bash
cargo test -p maestro-web --test embedded_bundle
```
Expected: PASS — `build.rs` ran `pnpm build`, repopulating `web/dist`, so the
embedded index now contains `id="app"`. Confirm `web/dist/assets/` has the
built JS/CSS again.
- [ ] **Step 5: Lint**
```bash
cargo clippy -p maestro-web --all-targets -- -D warnings
cargo fmt --all
```
Expected: clean.
- [ ] **Step 6: Commit**
```bash
git add crates/maestro-web/build.rs crates/maestro-web/tests/embedded_bundle.rs
git commit -m "feat(maestro): build maestro-web SPA bundle in build.rs
include_dir! now embeds a freshly built web/dist on every compile; pnpm +
node are hard build requirements. Fixes the placeholder served by
cargo install --git.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Installer preflights the frontend toolchain
**Files:**
- Modify: `crates/maestro-installer/src/main.rs` (add `WebPrereq` enum + `classify_web_prereq` + `frontend_toolchain_present`; wire into `handle_web` at the opt-in point, ~line 389–396)
- Test: `crates/maestro-installer/src/main.rs` (new `#[cfg(test)] mod tests`)
- [ ] **Step 1: Write the failing test**
Append to the end of `crates/maestro-installer/src/main.rs` (no test module exists yet):
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prereq_ok_when_toolchain_present() {
assert_eq!(classify_web_prereq(true, true), WebPrereq::Ok);
assert_eq!(classify_web_prereq(true, false), WebPrereq::Ok);
}
#[test]
fn prereq_prompts_when_missing_and_interactive() {
assert_eq!(classify_web_prereq(false, true), WebPrereq::PromptMissing);
}
#[test]
fn prereq_skips_when_missing_and_noninteractive() {
assert_eq!(classify_web_prereq(false, false), WebPrereq::SkipMissing);
}
}
```
- [ ] **Step 2: Run the test and verify it fails**
```bash
cargo test -p maestro-installer classify_web_prereq
```
Expected: FAIL to compile — `WebPrereq` and `classify_web_prereq` don't exist yet.
- [ ] **Step 3: Add the enum, decision function, and toolchain probe**
In `crates/maestro-installer/src/main.rs`, just above `fn handle_web(` (line 350), add:
```rust
/// What to do at the (opt-in) web step given whether pnpm + node are present.
#[derive(Debug, PartialEq, Eq)]
enum WebPrereq {
/// Toolchain present — install maestro-web normally.
Ok,
/// Missing and we can ask: prompt the operator to abort or skip.
PromptMissing,
/// Missing and we can't prompt (non-interactive refresh): warn + skip.
SkipMissing,
}
fn classify_web_prereq(toolchain_present: bool, interactive: bool) -> WebPrereq {
if toolchain_present {
WebPrereq::Ok
} else if interactive {
WebPrereq::PromptMissing
} else {
WebPrereq::SkipMissing
}
}
/// True iff both `pnpm` and `node` respond to `--version` on PATH. maestro-web's
/// build.rs needs them to build the embedded SPA bundle.
fn frontend_toolchain_present() -> bool {
["pnpm", "node"].iter().all(|tool| {
Command::new(tool)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
})
}
```
- [ ] **Step 4: Run the unit test and verify it passes**
```bash
cargo test -p maestro-installer classify_web_prereq
```
Expected: PASS (3 tests).
- [ ] **Step 5: Wire the preflight into `handle_web`**
In `handle_web`, the existing flow is:
```rust
if !want_web {
return Ok(false);
}
// Install the binary. Even if cargo-install fails we keep going so the
// operator can fix the cause and re-run; the existing daemon work
// doesn't need to be undone.
let source = build_source(args);
```
Insert the preflight between the `if !want_web` block and `let source =`:
```rust
if !want_web {
return Ok(false);
}
// maestro-web's build.rs requires pnpm + node. Check before cargo install so
// a missing toolchain surfaces a clear choice instead of a raw build panic.
match classify_web_prereq(frontend_toolchain_present(), interactive) {
WebPrereq::Ok => {}
WebPrereq::PromptMissing => {
eprintln!("\nmaestro-web needs pnpm + node, which aren't installed.");
if confirm("Abort the whole install? (No = skip web UI)", false)? {
return Err(
"aborted: maestro-web prerequisites (pnpm + node) are missing".into(),
);
}
eprintln!("Skipping maestro-web install.");
return Ok(false);
}
WebPrereq::SkipMissing => {
eprintln!(
"\nSkipping maestro-web refresh: pnpm + node not installed. \
Existing unit left in place."
);
return Ok(false);
}
}
// Install the binary. Even if cargo-install fails we keep going so the
// operator can fix the cause and re-run; the existing daemon work
// doesn't need to be undone.
let source = build_source(args);
```
- [ ] **Step 6: Verify the crate builds, lints, and tests pass**
```bash
cargo build -p maestro-installer
cargo clippy -p maestro-installer --all-targets -- -D warnings
cargo test -p maestro-installer
cargo fmt --all
```
Expected: all clean/pass. (`Command` and `confirm` are already imported/defined in the file.)
- [ ] **Step 7: Commit**
```bash
git add crates/maestro-installer/src/main.rs
git commit -m "feat(maestro): preflight pnpm + node at installer web step
The opt-in maestro-web step now checks for the frontend toolchain its
build.rs requires; missing tools prompt abort/skip (interactive) or skip
the refresh (non-interactive) instead of failing cargo with a raw panic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: CI installs Node + pnpm
**Files:**
- Modify: `.gitea/workflows/ci.yml`
- [ ] **Step 1: Add a Node + pnpm step**
`clippy` and `test` compile `maestro-web`, which now needs the toolchain. After
the "Install rustup + stable toolchain" step (line 27) and before "Cache
cargo", add:
```yaml
- name: Install Node.js + pnpm
run: |
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y --no-install-recommends nodejs
corepack enable
corepack prepare pnpm@9.15.4 --activate
```
`corepack prepare pnpm@9.15.4` matches the `packageManager` pin in
`web/package.json`, keeping CI deterministic.
- [ ] **Step 2: Verify locally that the affected jobs compile**
CI can't be run locally, but confirm the commands the runner executes still
work on a machine with the toolchain:
```bash
cargo clippy --all-targets -- -D warnings
cargo test --all
```
Expected: clean / all pass (these now exercise `maestro-web`'s `build.rs`).
- [ ] **Step 3: Commit and push**
```bash
git add .gitea/workflows/ci.yml
git commit -m "ci(maestro): install node + pnpm for maestro-web build.rs
Compiling maestro-web now requires the frontend toolchain to build the
embedded SPA bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git push
```
- [ ] **Step 4: Confirm CI is green**
Poll the Gitea CI run for this push (per project preference: rely on CI rather
than long local builds). Expected: `ci / rust` job passes including the new
Node/pnpm step.
---
## Self-Review Notes
- **Spec coverage:** §1 build.rs → Task 1; §2 static_bundle unchanged → no task (intentional); §3 installer preflight (prompt abort/skip; non-interactive skip) → Task 2; §4 CI → Task 3; §5 Justfile unchanged → no task (intentional).
- **Type consistency:** `WebPrereq::{Ok,PromptMissing,SkipMissing}`, `classify_web_prereq(bool, bool)`, and `frontend_toolchain_present()` are used identically in the definition (Task 2 Step 3), the wiring (Step 5), and the tests (Step 1).
- **Placeholders:** none — every code/command step is concrete.
@@ -1,316 +0,0 @@
# Maestro — Prototype Design
**Date**: 2026-05-24
**Status**: Approved (brainstorm)
**Scope**: First-pass prototype. No production polish.
## What this is
A unified terminal-session orchestrator. One place — laptop, desktop, or phone — to see and attach to every interactive shell or agent (Claude Code, opencode, plain bash) running across every machine you care about: workstation, desktop, dev VM, always-on VMs, and small LXC containers.
The interface stays a terminal (xterm.js in the browser for the prototype). Sessions persist via tmux on whichever host owns them. Maestro is a thin coordination layer on top, not a terminal-emulator rewrite.
## Why
Today: Claude Code runs locally for editing the host, and inside a dev VM for project work in dangerous mode. The VM uses agent-deck to keep sessions alive. Reaching those sessions means SSH-ing in first, no unified view, no side-by-side, and no phone access. Maestro removes the SSH-in step and adds a phone client.
## Success criterion for the prototype
A working demo where:
1. Laptop and phone both load the maestro web UI.
2. The session list shows tmux sessions from at least two hosts (laptop + dev VM).
3. A new session can be spawned from the UI on a chosen host, optionally backed by a fresh git worktree.
4. Both clients can attach to the same session concurrently and see live output.
5. Closing a client tab doesn't kill the session; reattaching resumes it.
UI polish, layout/tiling, edge-case terminals — all out of scope.
## Architecture
```
┌──────────────────────┐
│ Registry │
│ (always-on VM) │
│ │
│ • presence index │
│ • session catalog │
│ • serves web client │
└──────┬───────────────┘
│ WS (control only,
│ no PTY bytes)
┌──────────────────────────┼──────────────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Daemon │ │ Daemon │ │ Daemon │
│ (laptop) │ │ (dev VM) │ │ (LXC) │
│ │ │ │ │ │
│ tmux ↔ WS │ │ tmux ↔ WS │ │ tmux ↔ WS │
└──────▲──────┘ └──────▲──────┘ └──────▲──────┘
│ │ │
│ WS streams (PTY bytes, peer-to-peer over the operator's network)
│ │ │
└──────────────┬───────────┴──────────────┬───────────┘
│ │
┌───────▼───────┐ ┌───────▼───────┐
│ Web client │ │ Web client │
│ (browser) │ │ (phone) │
└───────────────┘ └───────────────┘
```
Three components, all reachable over an operator-managed network (LAN, VPN, mesh — maestro is network-agnostic; the operator configures bind addresses and ensures clients can route to them):
- **Registry** — one always-on instance. Knows who's online and what sessions exist. Serves the web client bundle. Never touches PTY bytes.
- **Daemon (`maestro daemon`)** — one per host. Wraps local tmux. Streams sessions directly to clients over WebSocket.
- **Web client** — one TS bundle. Connects to the registry for the catalog, then directly to whichever daemon owns the session being attached.
The registry is a phone book, not a relay. This keeps it tiny and pushes byte traffic to a direct daemon ↔ client link. Confidentiality of that link is the operator's responsibility (Wireguard, a private LAN, mTLS in a future iteration — maestro itself does not encrypt at the transport layer beyond what the operator's network provides).
## Components
### Registry
Responsibilities:
- Accept persistent WS connections from daemons (presence + session-list updates).
- Issue short-lived JWTs to authenticated clients for daemon access.
- Serve the web client bundle and a small HTTP API.
- Broadcast catalog changes to connected web clients via a `/api/events` WS.
Non-responsibilities:
- No PTY bytes through this process.
- No persistent storage in the prototype. State is in-memory; daemons re-register on reconnect.
- No multi-user model. One shared secret, one effective user.
Runs as a single Rust binary on a designated VM. Listens on a configured `bind_addr` (default `127.0.0.1:8080`; operator sets the actual reachable address — e.g. a Wireguard interface IP — in `[registry].bind_addr` of the unified config at `~/.config/maestro/config.toml`).
### Daemon (`maestro daemon`)
Responsibilities:
- Connect to the registry on startup, identify with the shared token, advertise its current sessions.
- Maintain a tmux session list. Spawn / kill tmux sessions on demand.
- Expose a per-session WS endpoint streaming PTY bytes both directions.
- Handle git worktree creation on session create, when requested.
- Recover existing sessions on restart by scanning tmux for names matching its naming pattern.
Non-responsibilities:
- Doesn't adopt foreign tmux sessions (only the ones it created — names prefixed `maestro-<id>`).
- Doesn't relay to other daemons. It only knows about local tmux.
- Doesn't manage its own auth tokens; the registry issues them.
Runs as a single Rust binary on every host that holds sessions. Small footprint matters because some hosts are LXC containers. Listens on a configured `bind_addr` (default `127.0.0.1:8081`; operator sets the actual reachable address in `[daemon].bind_addr` of the unified config at `~/.config/maestro/config.toml`). Also requires `[shared].registry_url` so it knows where to register on startup.
### Web client
Responsibilities:
- Authenticate to the registry (cookie set on first visit; assumes the operator restricts who can reach the registry's bind address).
- Render the session catalog as a tree, using each session's `metadata.label` (e.g. `personal/maestro`) to derive grouping.
- Provide search across session names and labels.
- Open a WS to a daemon to attach to a chosen session; render via xterm.js.
- Support concurrent attaches (multiple tabs to the same session work).
- Spawn a new session through a small form: pick host, working dir, command, optional worktree.
Non-responsibilities (prototype):
- No split-pane / side-by-side. One session at a time per tab — but you can open two browser tabs to two sessions, which gets you side-by-side via the OS.
- No theming / settings UI.
- No mobile-specific UX beyond "works in mobile Chrome at a reasonable touch size."
TypeScript + xterm.js. Build tool TBD in implementation (Vite is the default); frontend framework choice is non-architectural and deferred to the plan.
## Wire protocol
Three pairs of conversations. All JSON unless otherwise noted.
### Daemon ↔ Registry — single persistent WS, daemon-initiated
| Direction | Message | Purpose |
|---|---|---|
| D→R | `Hello { daemon_id, hostname, version, token }` | identify, auth |
| D→R | `SessionsSnapshot { sessions: [Session] }` | full list at startup |
| D→R | `SessionAdded { session }` | diff |
| D→R | `SessionRemoved { id }` | diff |
| D→R | `SessionUpdated { session }` | diff |
| D→R | `Heartbeat` | every ~10s |
| D→R | `CreateSessionResult { reply_to, result }` | reply to a `CreateSession` request, carries the new `Session` or an error |
| D→R | `KillSessionResult { reply_to, result }` | reply to a `KillSession` request |
| R→D | `Ack` / `Error { code, message }` | acks, faults |
| R→D | `CreateSession { request, reply_to }` | registry asks daemon to spawn a session; `reply_to` correlates with the result |
| R→D | `KillSession { id, reply_to }` | registry asks daemon to kill; `reply_to` correlates with the result |
The HTTP `POST /api/sessions` handler in the registry holds the client request open while it sends `CreateSession` to the chosen daemon over the WS, then completes the response when it receives the matching `CreateSessionResult`. Same pattern for `DELETE`. Each request gets a fresh `reply_to` UUID. Timeout is 10s; after that the registry returns 504 to the client.
### Client ↔ Registry — HTTP for queries, one WS for live updates
| Endpoint | Purpose |
|---|---|
| `GET /` | static web client bundle |
| `GET /api/daemons` | list connected daemons |
| `GET /api/sessions` | flat session list |
| `POST /api/sessions` | spawn (request forwarded to chosen daemon) |
| `DELETE /api/sessions/:id` | kill (forwarded to owning daemon) |
| `GET /api/sessions/:id/connect-info` | returns `{ daemon_url, jwt }` for direct attach |
| `WS /api/events` | live catalog + presence deltas |
Auth: the registry binds its listener to the operator-configured `bind_addr` only, so packets from elsewhere never reach the process. The operator is expected to point this at a private interface (Wireguard, LAN, etc.) — maestro does not police the network itself. On first request, the registry sets an httpOnly cookie signed with the shared secret; all subsequent API calls require it. The cookie is the second factor against anyone who happens to have routing to the bind address.
### Client ↔ Daemon — one WS per attached session
`WS /sessions/:id/stream?token=<jwt>`
- **Text frames**: control JSON — `Resize { cols, rows }`, `Ping`, `Pong`, `Error { ... }`.
- **Binary frames**: raw PTY bytes, bidirectional.
JWT is short-lived (~5 min) and validated by the daemon using the shared secret. No callback to registry.
Daemon broadcasts incoming PTY output to all currently-attached client WS connections for that session.
## Session model
A `Session` is the only first-class entity. The daemon is the source of truth for its own sessions.
```rust
struct Session {
id: Uuid, // maestro's id; tmux session name is `maestro-<id>`
daemon_id: String,
cwd: PathBuf, // resolved at create time (may be a worktree path)
command: String,
env: HashMap<String, String>,
metadata: HashMap<String, Value>, // opaque; common keys: name, label
status: SessionStatus, // Running | Dead
created_at: DateTime<Utc>,
}
```
`metadata` is intentionally opaque to the daemon and registry. Clients use convention — e.g. `label: "personal/maestro"` becomes a tree path. The protocol doesn't enforce shape.
### Create options
`POST /api/sessions` body (forwarded to daemon as `CreateSession`):
```json
{
"daemon_id": "...",
"cwd": "/home/naps62/projects/maestro",
"command": "claude",
"env": { "FOO": "bar" },
"metadata": { "name": "maestro-impl", "label": "personal/maestro" },
"worktree": {
"base_repo": "/home/naps62/projects/maestro",
"branch": "feat/foo",
"cleanup_on_kill": false
}
}
```
`worktree` is optional. When present, the daemon runs `git worktree add <generated-path> -b <branch>` rooted at `base_repo`, then uses the generated path as `cwd`. If `cleanup_on_kill` is true, the daemon prunes the worktree on `KillSession`. Default is false — the worktree stays on disk.
### Lifecycle
```
Create Attach Detach Kill
│ │ │ │
▼ ▼ ▼ ▼
client→registry client→daemon close WS client→daemon
POST /sessions WS /sessions/:id (session DELETE /sessions/:id
│ │ keeps │
▼ ▼ running) ▼
registry→daemon daemon attaches daemon kills
CreateSession via tmux pipe-pane tmux session,
│ │ optionally
▼ ▼ prunes worktree
daemon: (optional) bidirectional stream
git worktree add
│
▼
daemon: tmux new-session
-d -s maestro-<id> -c <cwd> <cmd>
│
▼
daemon→registry
SessionAdded
```
### Crash and restart recovery
- **Daemon restart**: on boot, the daemon lists tmux sessions matching `maestro-*`. For each, it reads a sidecar JSON in `~/.local/state/maestrod/sessions/<id>.json` (written at create time) to recover metadata. Sessions whose sidecars are missing are still advertised but with empty metadata. The daemon re-establishes the WS to the registry and sends a fresh `SessionsSnapshot`.
- **Registry restart**: in-memory state is lost. Daemons hold an outbound WS with exponential-backoff retry; within seconds of registry coming back, every daemon has re-registered and the catalog is rebuilt. Clients holding direct daemon WS streams are unaffected — only catalog-listing breaks temporarily.
- **Client disconnect**: just closes the WS. Session keeps running. Reconnecting re-attaches.
- **Daemon dies / unreachable**: registry marks it offline after a missed heartbeat (~30s). Its sessions disappear from the catalog. When it comes back, they reappear.
## Auth
- Registry generates the shared token at first run, printed once, stored as `[shared].token` in `~/.config/maestro/config.toml` (env var name: `MAESTRO_TOKEN`).
- Same token deployed to each daemon (config file or env var) and used in `Hello`.
- Web client gets a cookie from the registry on first visit. All registry API calls use it.
- Per-attach JWT issued by registry, signed with the shared secret, validated locally by the daemon. ~5 min expiry; client refreshes by re-requesting `GET /api/sessions/:id/connect-info`.
Single-user trust model. The operator's network (Wireguard / private LAN / etc.) is the perimeter; the token is the second factor against anyone who happens to have routing to the bind addresses.
## Repo layout
Cargo workspace. NAP-245 collapsed the three binaries (`maestro`, `maestrod`, `maestro-registry`) into a single `maestro` binary with feature-gated subcommands. Library crates kept their existing shape; per-binary `main.rs` files are gone.
```
maestro/
├── Cargo.toml # workspace
├── crates/
│ ├── maestro/ # umbrella binary: `maestro <list|spawn|attach|kill|set-registry|policy|daemon|registry>` (features: cli/daemon/registry)
│ ├── maestro-protocol/ # message types, shared between subsystems
│ ├── maestro-config/ # unified on-disk config schema
│ ├── maestro-cli/ # CLI subcommand types + dispatch (library)
│ ├── maestro-daemon/ # daemon subsystem (library + `maestro daemon` dispatch)
│ ├── maestro-registry/ # registry subsystem (library + `maestro registry` dispatch)
│ ├── maestro-web/ # standalone `maestro-web` browser-UI binary
│ ├── maestro-installer/ # `maestro-install` wizard
│ └── maestro-e2e/ # cross-process integration tests
├── web/
│ ├── src/
│ ├── package.json
│ └── vite.config.ts
├── docs/superpowers/specs/ # this file, future specs
├── .claude/
│ └── linear.json
└── CLAUDE.md
```
Thin builds for single-role hosts: `cargo install --no-default-features --features {cli|daemon|registry} maestro` produces a binary with only that subsystem's dependencies compiled in.
## Out of scope (prototype)
| Deferred | Why |
|---|---|
| UI polish, themes, animations | prove it works first |
| Side-by-side / split-pane layout in one tab | protocol supports concurrent attaches; two browser tabs is the workaround |
| Native TUI client, Leptos/WASM client, native Android | protocol is stable; addable later |
| Public internet exposure / TLS on the wire | maestro doesn't terminate TLS or police the network; operator runs it on a private network |
| Multi-user / roles / RBAC | single user, single token |
| Persistent registry storage | in-memory, re-derived from daemon reconnects |
| Auto-adopting foreign tmux sessions | daemon only owns sessions it created |
| Auto-restart of dead inner processes | tmux keeps the session; if the inner command exits, it exits |
| Recording / replay | future |
| File transfer / drag-drop | future |
## Testing strategy
- **`maestro-protocol`**: unit tests on every message variant, JSON round-trip, schema stability.
- **`maestro-daemon`**: integration tests against a real `tmux` binary in a temp `$HOME` (with `TMUX_TMPDIR` set). Worktree creation tested against a tmpdir git repo (`git init`, then exercise `worktree add` + cleanup).
- **`maestro-registry`**: tests with a mock daemon WS client. Cover register, heartbeat-timeout, session diff propagation, JWT issuance.
- **End-to-end**: harness that spawns one registry + two daemons as subprocesses in temp dirs, then a programmatic client (using `tokio-tungstenite`) that lists, spawns, attaches, sends bytes, kills. Asserts on both daemons' tmux state and on registry catalog deltas.
- **Web client**: smoke-only for the prototype. Manual verification in desktop Chrome and Android Chrome. A single Playwright test if it doesn't slow CI.
CI runs the Rust workspace; `npm run build` is gated on it succeeding.
## Open questions to resolve during implementation
These don't block the design but should be settled early in the plan:
- **Frontend framework**: vanilla TS, Preact, SolidJS, or Leptos-WASM from day one? Pick during the implementation plan; doesn't affect the protocol.
- **Sidecar persistence file format**: JSON is fine; only flagged because if it grows, we'll want a single sqlite per-daemon instead.
- **PTY backend details**: the prototype uses `tmux pipe-pane` for output capture and `tmux send-keys` for input. If pipe-pane latency or framing turns out to be unworkable, fallback is `portable-pty` wrapping `tmux attach`. This is implementation-only — no protocol impact.
- **Registry HTTP framework**: `axum` is the default Rust choice; reconfirm at plan time.
- **CLI scope**: keep it minimal (`maestro list`, `maestro spawn`, `maestro attach`) or fuller. Probably minimal.
@@ -1,408 +0,0 @@
# Maestro TUI — design
**Status:** draft (brainstormed 2026-05-28)
**Scope:** new `maestro tui` subcommand on the umbrella `maestro` binary, providing a multi-session terminal UI that talks to the local daemon.
**Related:**
- `docs/superpowers/specs/2026-05-24-maestro-design.md` — overall maestro architecture
- `docs/superpowers/specs/2026-05-26-connection-orchestration.md` — broker / catalog-mirror surface this TUI rides on
- NAP-230 (CLI attach footer) — adjacent, separate; NAP-244 (CLI through broker) — orthogonal
## 1. Goals & non-goals
### Goals
- A keyboard-and-mouse driven terminal UI that exposes the same surface as `maestro-web`: list daemons + sessions, spawn, attach, kill.
- Multi-session viewing: attach to several sessions at once, switch between them as tabs, split tabs into side-by-side panes — mental model is vim's buffers/windows/tabs.
- Sessions remain live in the background even when not visible (their PTY bytes are consumed continuously into a maintained screen state).
- Configurable keymap: every action is bindable, the shipped defaults are just defaults.
- First-class mouse support (click sidebar / tabs / panes, drag split dividers).
### Non-goals (v1)
- A vim-style `:` command palette. Parked as v1.5 candidate once direct bindings prove their reach.
- Persisting tabs/splits across TUI restarts.
- Saving named split layouts ("workspaces") to disk.
- Pixel-perfect rendering across exotic terminal emulators — we trust ratatui + crossterm.
### Phasing
The full goal set above is what the design supports — but for delivery the work is staged so we ship something useful before the split-tree work is done:
- **v1** — sidebar + tab bar + one pane per tab, full multi-session liveness, configurable keymap, mouse. No splits. The layout tree (§3.3) is implemented but every tab has exactly one leaf.
- **v1.5** — splits enabled. The pane-tree code path is already there; v1.5 unlocks `SplitHorizontal` / `SplitVertical` actions and the focus-motion / resize bindings.
Same crate, same implementation plan, just two milestones inside it.
### Out of scope (forever)
- Adding persistence to maestro. Sessions persist because tmux owns them on the daemon side. The TUI is a multi-attach *client*; closing it closes its WS connections, the sessions keep running.
## 2. Architecture
```
┌──────────── maestro tui (process) ─────────────┐
│ │
│ ┌──────────┐ ┌──────────────────────────┐ │
catalog WS ───► │ │ Catalog │ │ Render loop │ │
HTTP REST ────► │ │ store │◄──►│ (ratatui, 30 Hz max) │ │
│ └──────────┘ │ sidebar │ tabs │ panes │ │
│ └──────────────────────────┘ │
│ ▲ ▲ │
│ ┌────────────────────┐ │ │ │
│ │ Session emulators │──┘ │ │
│ │ (1 task / session) │ │ │
│ │ vt100 ↔ WS ↔ buf │ │ │
│ └────────────────────┘ │ │
│ │ │
│ ┌────────────────────┐ │ │
│ │ Input router │──────────┘ │
│ │ (crossterm events) │ │
│ └────────────────────┘ │
└────────────────────────────────────────────────┘
│ ▲ ▲
▼ │ │
┌──────── local maestro daemon ────────┐
│ GET /api/sessions /api/daemons │
│ POST /api/sessions │
│ DEL /api/sessions/:id │
│ WS /api/events │
│ WS /api/attach/:id ×N │
└──────────────────────────────────────┘
```
Four concurrent concerns inside the TUI process, each its own Tokio task / unit:
1. **Catalog store** — single source of truth for the daemons-and-sessions tree. Bootstrapped via `GET /api/daemons` + `GET /api/sessions`, then kept live by `WS /api/events`.
2. **Session emulators** — one task per *attached* session, owning a `tokio_tungstenite` WS to `/api/attach/:id` and a `vt100::Parser`. Stays alive even when the session is not visible — that is how background liveness works.
3. **Input router** — owns the crossterm event stream. Distinguishes between TUI-mode keys (sidebar nav, tab/split keys, action keys, mouse events) and pass-through keys (anything else when a session pane is focused, forwarded as binary frames to that session's WS sink).
4. **Render loop** — at most ~30 Hz, redraws on (a) any emulator screen-dirty bit, (b) catalog change, (c) input that changes focus/layout. Composes: sidebar (left), tab bar (top), pane tree (rest).
The TUI talks **only** to the local daemon's `/api/*` surface — same contract `maestro-web` uses. Peer sessions reach us via the catalog mirror (NAP-246); attaching to a peer session goes through the broker (NAP-247) without any TUI-side awareness of "remote".
The new crate is `crates/maestro-tui/`. It lives behind a `tui` cargo feature on the `maestro` umbrella binary, included in the default feature set. Registry-only / daemon-only hosts opt out per the established NAP-245 pattern (`--no-default-features --features registry`).
## 3. Components
### 3.1 Sidebar (`sidebar::Sidebar`)
Left column, default 28 cols wide, togglable. Two-level tree: daemons → sessions, ordered (self first, peers alphabetical). Each session row shows label, command, age, and a status glyph:
- ● attached in current TUI
- ○ live but not attached
- ✗ ended (kept visible briefly, then aged out by catalog events)
Sidebar is navigation-only. Possible actions: highlight motion, attach highlighted, toggle-attach highlighted, collapse/expand daemon. It never enters Insert mode and never forwards keystrokes to a session.
### 3.2 Tab bar (`tabs::TabBar`)
Top row of the main area. A tab is a named workspace owning a layout tree. Tabs are TUI-local — closing the TUI does not persist them.
### 3.3 Layout tree (`layout::Layout`)
A binary tree per tab. Leaves are `PaneId`s; internal nodes are horizontal or vertical splits with a ratio in `[0.1, 0.9]`. Operations:
- `split(direction)` — replace the focused leaf with a split. The new pane inherits the same session by default (vim semantics: `:split` shows the current buffer in both).
- `close()` — remove the focused leaf, collapse the parent.
- `move_focus(direction)` — geometric: find the nearest leaf in `h/j/k/l` direction.
- `resize(direction, delta)` — adjust the nearest ancestor split's ratio.
Each leaf binds to a `SessionId`. Multiple leaves *can* bind to the same session — viewing the same session in two splits, identical to vim opening a buffer in two windows. The session's emulator owns one canonical screen; multiple panes render that same screen.
### 3.4 Spawn modal (`spawn_modal::SpawnModal`)
Standalone overlay form for creating a session. Fields: daemon (dropdown sourced from the catalog), cwd (text), command (text), label (text, optional), env entries (repeatable `KEY=VALUE`). Submit constructs a `CreateSessionRequest` and `POST /api/sessions`; the new session appears in the sidebar via the events WS, not by waiting on the POST response. Field set matches the existing `SpawnArgs` in `crates/maestro-cli/src/cli.rs`.
### 3.5 Attach pane (`pane::Pane`)
Renders the bound session's emulator screen via `vt100::Screen` into a `ratatui` `Buffer`. When focused in Insert mode, all keystrokes that aren't the leader are forwarded to that session's WS as binary frames. On every render, if the pane's allocated `(cols, rows)` differs from the last value sent for that session, the TUI sends a `ClientControl::Resize`. When multiple panes show the same session at different sizes, the per-dimension minimum wins: `cols = min(p.cols)` and `rows = min(p.rows)` across all panes bound to the session, computed independently. Matches tmux's convention when a session is attached at two sizes.
### 3.6 Help overlay (`help::Help`)
Transient overlay listing all current bindings (resolved through the active keymap). Closes on any key. This is the discoverability mechanism in lieu of a command palette.
### 3.7 Modal model
Three mutually-exclusive modes:
- **Normal** — sidebar nav, tab/split keys, action keys (spawn, kill, help). Default.
- **Insert** — pane has full passthrough; the only intercepted key is the **leader**. Enter from Normal when a pane is focused; leave with the configured `LeaveInsert` binding.
- **Modal overlay** — Spawn modal, Kill-confirm, or Help. Captures all input until dismissed.
The leader is the one byte the TUI consumes in Insert mode — same idea as tmux's prefix. Default `Ctrl-a`, configurable.
### 3.8 Action set (the verbs the TUI knows about)
This is the closed list of things the user can ask the TUI to do. Bindings map input → action; the action surface itself is the stable contract.
| Action | Effect |
|---|---|
| `EnterInsert` | Active pane begins forwarding all input (except leader) to the session WS |
| `LeaveInsert` | Return to Normal mode |
| `FocusSidebar` | Move focus to the sidebar tree |
| `FocusPane(Left/Right/Up/Down)` | Geometric focus motion across the current tab's layout |
| `AttachHighlighted` | Attach to the sidebar's highlighted session: if a pane is focused (in the main area), bind the session to that pane (replacing its previous binding); if focus is on the sidebar, open the session in a new tab |
| `ToggleAttachHighlighted` | Attach/detach the highlighted session without changing focus |
| `OpenSpawnModal` | Open the spawn form |
| `KillHighlighted` | Kill highlighted session (always followed by a confirm overlay; not bindable to bypass) |
| `NewTab` / `CloseTab` / `NextTab` / `PrevTab` / `GotoTab(n)` | Tab management |
| `SplitHorizontal` / `SplitVertical` / `ClosePane` | Split-tree management |
| `ResizePane(dir, delta)` | Adjust nearest split ratio |
| `ToggleSidebar` | Hide / show sidebar |
| `ShowHelp` | Open the help overlay |
| `Quit` | Quit (confirm if any pane has an active attach) |
### 3.9 Keymap (`keymap::Keymap`)
A two-level table loaded from config: `mode → (input → action)`, with separate Normal and Insert tables (Insert is sparse — usually only the leader is bound). Input lives in a canonical form: `"ctrl-w h"`, `"g t"`, `"mouse:click(pane)"`, `"mouse:dblclick(sidebar-row)"`, `"mouse:drag(split-divider)"`.
Maestro ships a default keymap that covers every action — recorded as the **shipped default**, not a hardcoded contract. Defaults to be finalized during implementation; the configuration surface is the part being committed to here.
Config lives at `[tui.keymap]` in `~/.config/maestro/config.toml`:
```toml
[tui.keymap]
leader = "ctrl-a"
chord_timeout_ms = 1000
[tui.keymap.normal]
# (illustrative — not contractual)
"i" = "EnterInsert"
"s" = "OpenSpawnModal"
"x" = "KillHighlighted"
"ctrl-t" = "NewTab"
"ctrl-w h" = "FocusPane(Left)"
"mouse:click(pane)" = "FocusPaneAtCursor"
"mouse:drag(split-divider)" = "ResizePaneByDrag"
"mouse:click(sidebar-row)" = "HighlightSidebarRow"
"mouse:dblclick(sidebar-row)" = "AttachHighlighted"
"mouse:click(tab)" = "GotoTabAtCursor"
[tui.keymap.insert]
"<leader>" = "LeaveInsert"
```
User can rebind, unbind (`= "Noop"`), or supply a partial table that merges over the shipped defaults.
**Chord handling.** Prefixes (`g t`, `ctrl-w h`) are handled by a small state machine inside the resolver: if the current key extends a known prefix, hold it for one cycle waiting for the next key; if the next key doesn't complete a binding, drop the chord. Timeout (default 1s, configurable via `chord_timeout_ms`) flushes pending chords.
**Validation.** Parsed at TUI startup in two passes:
1. **Lex pass** — canonicalise each key string. Bad input (`"ctrl-glorb"`) collected with line numbers.
2. **Resolve pass** — every action name resolved against the closed `Action` enum. Unknown actions collected.
If either pass produces errors, the TUI prints the diagnostic list and exits 2. A `--keymap-check` flag (validate without launching) makes this scriptable. Prefix conflicts (`g` shadows `g t`) are warnings, not errors — last-load-wins.
### 3.10 Mouse support
Crossterm exposes mouse events; ratatui forwards them. The TUI consumes them in two layers:
1. **Region hit-test** — the render layer tags regions (sidebar rows, tab cells, split dividers, panes) with stable identifiers; the input router resolves a mouse coordinate to one tagged region.
2. **Action binding** — mouse events become canonical input strings; the same keymap table handles them as keyboard input.
Mouse passthrough to the session (for tmux-aware programs that want their own mouse) is enabled when a pane is in Insert mode and the underlying session indicates mouse support; otherwise the TUI consumes the click. The exact heuristic for "underlying session indicates mouse support" is to be validated during implementation — `TERM` inspection is the obvious starting point.
## 4. Data flow
### 4.1 Catalog bootstrap & live updates
On startup the TUI hits `GET /api/daemons` and `GET /api/sessions` against the local daemon (`http://127.0.0.1:7070` by default, configurable via `[shared].daemon_url`/`MAESTRO_DAEMON_URL`). The two responses populate `CatalogStore`:
```rust
struct CatalogStore {
daemons: HashMap<DaemonId, DaemonInfo>,
sessions: HashMap<SessionId, Session>,
version: u64, // bumped on every change
}
```
Then it opens `WS /api/events`. The receive task deserialises `RegistryEvent` frames (already the protocol surface emitted by `LocalEventBus`, NAP-248) and applies them:
- `SessionCreated` → insert
- `SessionUpdated` → replace
- `SessionEnded` → remove and mark any attached emulator as `Ended`
- `DaemonJoined` / `DaemonLeft` → likewise
Each apply bumps `version` and wakes the render loop via a `Notify`.
### 4.2 Attach lifecycle
Triggered by the `AttachHighlighted` action:
1. Look up the highlighted session in the catalog.
2. Open `WS /api/attach/:id` against the **local** daemon. No client auth — the local-bind trust model is "whoever can reach `bind_addr`" (matches `local_api.rs` v1). The broker handles JWT minting for cross-daemon hops internally; that's transparent to the TUI.
3. Spawn a `SessionTask`:
- Owns `(ws_sink, ws_stream, parser: vt100::Parser, screen_arc: Arc<Mutex<vt100::Screen>>)`.
- Receive side: `Message::Binary(bytes)` → `parser.process(&bytes)`; on dirty screen, notify the render loop.
- Receive side, text frames: route `ClientControl::Ping` back as `Pong`; other control frames are no-ops in v1.
- Send side: receives `SessionInput` enum from the input router — `Bytes(Vec<u8>)` becomes a binary frame; `Resize { cols, rows }` becomes a `ClientControl::Resize` text frame.
- On WS close: state transitions to `Ended`; the screen remains visible (frozen last frame) until the pane is closed.
```rust
struct SessionHandle {
tx: mpsc::Sender<SessionInput>,
screen: Arc<Mutex<vt100::Screen>>,
state: Arc<AtomicU8>, // Connecting | Live | Reconnecting | Ended
}
// Lives in:
struct SessionEmulators(HashMap<SessionId, SessionHandle>);
```
Panes hold a `SessionId` and look up the handle on every render — cheap and avoids stale references when a session ends and a new pane binds to a new one.
### 4.3 Resize propagation
Each pane, on render, computes its `(cols, rows)` from the ratatui `Rect` it is given. It compares with `last_sent_size: HashMap<SessionId, (u16, u16)>`. After the render pass, the per-session minimum across all panes bound to that session is recomputed; if it changed, one `Resize` is sent. Window-level SIGWINCH triggers a full re-layout; the same per-session min-size recompute handles propagation.
### 4.4 Input routing
Per frame the input router pulls events from the crossterm stream and runs:
```
Event → canonicalise(Event) # "ctrl-a", "g t", "mouse:click(pane)"
→ Keymap.resolve(mode, key) # → Option<Action>
→ if Some(action): dispatch(action)
→ if None: if mode == Insert and focus is a pane → forward bytes to that pane's SessionTask
else: ignore
```
Bytes forwarded to a session in Insert mode go straight onto the session task's input channel without TUI interpretation — that is how `Ctrl-C`/`Ctrl-Z`/`Esc` reach the remote shell. The leader is the **only** key the TUI consumes in Insert mode.
### 4.5 Spawn & kill
Spawn: `OpenSpawnModal` → form → submit constructs a `CreateSessionRequest` and `POST /api/sessions`. On 201, the catalog WS will emit a `SessionCreated` that adds it to the sidebar; the modal closes on submit, doesn't wait for the event.
Kill: `KillHighlighted` → confirm overlay → on confirm `DELETE /api/sessions/:id`. Same pattern — the event stream is the source of truth for the disappearance; the request just kicks it off.
If either request fails (4xx / 5xx / network), a status-bar toast surfaces the error for ~5s; catalog state is unchanged.
## 5. Error handling & resilience
### 5.1 Daemon unreachable at startup
If the initial `GET /api/web/info` (health probe) fails after a short retry (3× 500ms backoff), the TUI does not enter the main loop. It prints a single line on the inherited terminal explaining what it tried to reach and how to fix it (`maestro daemon configure`, `systemctl --user status maestro-daemon`), then exits 1.
### 5.2 Events WS reconnect
On disconnect: exponential backoff (1s base, 30s cap, ±25% jitter, infinite retries). Sidebar shows `△ catalog offline` in its footer until reconnect. On reconnect, re-fetch `/api/daemons` + `/api/sessions` and diff against `CatalogStore` — sessions that disappeared while disconnected become `Ended`, new ones appear. This avoids drift across reconnects without needing the events WS to replay missed events.
### 5.3 Attach WS drops (per-session)
A session's WS dropping does *not* end the session — only signals our client lost contact. The `SessionTask` transitions to `Reconnecting`, posts a status overlay on every pane bound to it (`reconnecting…`), and retries with the same backoff. On reconnect:
- Re-open `WS /api/attach/:id` on the local daemon.
- Send a fresh `ClientControl::Resize`.
- Do **not** clear the vt100 screen — last-known frame stays visible (frozen) until new bytes arrive.
If the underlying session has actually ended (daemon emits `SessionEnded` on the events WS while we're reconnecting), the task aborts reconnect and transitions to `Ended`.
### 5.4 vt100 parser glitches
`vt100::Parser::process` is infallible on malformed input — it discards unrecognized sequences. The user-visible failure mode is rendering glitches (wrong colour cells, wrap math off). Recovery: a per-pane `RedrawSession` action (default unbound, available to keymap) re-initialises the parser at the current size and asks the remote `tmux refresh-client -t maestro-<id>` via a daemon endpoint (`POST /api/sessions/:id/refresh`, **new in v1**, shared with `maestro-web`).
If `vt100` actually panics on input (shouldn't, but: parser bugs happen), the panic hook catches it, logs, and ends that one `SessionTask` cleanly — the rest of the TUI stays up.
### 5.5 Terminal restore on panic / abrupt exit
The TUI installs `panic::set_hook` and a `SIGTERM`/`SIGINT` handler that:
- Disable mouse capture
- Leave the alternate screen buffer
- Restore the original termios (saved at startup)
- Print the panic message / signal name
- Re-raise the signal so the parent shell sees the correct exit reason
A `Drop` impl on the same RAII guard handles the normal-exit path. Pattern matches the existing `RawTty` guard in `crates/maestro-cli/src/attach.rs`.
## 6. Testing strategy
### 6.1 Unit tests — pure logic, no I/O
- `keymap::parse` — table tests over the canonical form: valid key/chord strings parse, bad strings surface the right diagnostic, conflicting bindings produce the expected warning.
- `layout::Layout` — algebraic: split-then-close is identity, focus motion under arbitrary trees finds the geometrically-nearest leaf, ratio bounds are enforced. Property tests via `proptest`: "focus is always on a real leaf", "ratios sum to 1.0 within each split", "close-on-singleton collapses to empty".
- `action::dispatch` — given a `(CatalogStore, Layout, Mode)` snapshot and an `Action`, the post-state is deterministic. Covers edge cases (kill-while-focused-pane-shows-it, attach-when-already-attached, close-pane-when-only-one-left).
- `vt100` framing — bytes-in, screen-out. We don't test `vt100` itself, but we do test that our `SessionTask`'s parser correctly accumulates a partial CSI across `process` calls (WS frames have no guarantee of sequence alignment). Fixtures from real bash/vim/claude byte streams.
### 6.2 Integration tests — against a real daemon (in-process)
`crates/maestro-tui/tests/integration.rs` spins up a `SessionManager` + `LocalApiState` + axum router on a random port — same seam `maestro-e2e` uses. The TUI's networking layer is pointed at that port.
Coverage:
- Catalog bootstrap loads both REST snapshots and reaches steady state.
- Events WS receives `SessionCreated` after `POST /api/sessions` and updates the sidebar state.
- Attach against a daemon-owned bash session round-trips bytes (test feeds keystrokes through the input router, asserts the bash session sees them via `echo`'d output through the same WS into the parser).
- Reconnect: forcibly drop the events WS, assert backoff fires, assert state reconciles on reconnect.
Real `tmux`, isolated `$TMUX_TMPDIR`. Behind `cargo test --features integration`; CI runs them, default `cargo test` does not (slow).
### 6.3 Render snapshot tests
`ratatui::backend::TestBackend` for known-buffer assertions:
- Sidebar tree rendering (collapsed, expanded, with status glyphs).
- Tab bar at varying counts, with truncation.
- Split layouts (h-split, v-split, nested).
- Help overlay.
- Spawn modal.
Goldens stored under `tests/snapshots/`. Choice between `insta` or a small home-grown matcher made during implementation.
### 6.4 End-to-end via `maestro-e2e`
`maestro-e2e` (NAP-217) already spins registry + two daemons. We add a `tui` e2e target that drives the TUI by piping crossterm events into a substitutable Tokio source.
Coverage:
- "Spawn → catalog updates → attach → type → see output → kill" round-trip across two daemons.
- Sidebar shows peer sessions (catalog mirror); attach against a peer session goes through the broker (NAP-247) without TUI-side awareness of "remote".
Behind the same `--features e2e` gate.
### 6.5 What we explicitly don't test
- Pixel-perfect rendering across exotic terminals — trust `ratatui` + `crossterm`. Manual smoke per release.
- Mouse behaviour in CI — non-interactive runs don't expose mouse. Mouse hit-test logic (region tagging → resolved region) is covered by unit tests; the actual event plumbing is exercised manually.
- `vt100` correctness on weird ANSI — if a program breaks rendering, file a follow-up with the byte stream as a fixture.
### 6.6 Acceptance bar for v1 sign-off
Rough target: ~30 unit, ~6 integration, ~10 snapshot, ~2 e2e. Not a contract — a sanity check that we've covered each layer.
## 7. Open questions / follow-ups parked for after v1
- **Command palette** — vim-`:` style ex-line. To revisit once direct bindings prove their reach in real use.
- **Tab persistence** — saving the tab/split layout across TUI restarts. Probably wanted, but only after the layout primitives are stable.
- **Named workspaces** — saving and restoring multiple layouts.
- **Mouse passthrough heuristic** — finalize how we detect that the inner program wants mouse events so the TUI yields its mouse handling.
- **`wezterm-term` swap** — if `vt100` proves insufficient for some real program (vim/claude with exotic sequences), the emulator behind the `SessionTask` is swappable behind a thin trait.
- **`POST /api/sessions/:id/refresh`** — small new daemon endpoint behind the per-pane redraw recovery; share with `maestro-web`.
## 8. Repo layout
```
crates/
├── maestro/ # umbrella binary; `tui` feature (default-on) wires TUI subcommand
└── maestro-tui/ # new crate — all TUI logic
├── src/
│ ├── lib.rs # `run(Config) -> Result<()>`
│ ├── cli.rs # clap subcommand definition
│ ├── catalog.rs # CatalogStore, REST bootstrap, events WS
│ ├── session_task.rs # one task per attached session
│ ├── emulators.rs # SessionEmulators registry
│ ├── input.rs # crossterm event canonicalisation + router
│ ├── keymap.rs # config parsing, resolver, chord state machine
│ ├── action.rs # Action enum + dispatch
│ ├── layout.rs # binary split tree
│ ├── sidebar.rs # sidebar widget
│ ├── tabs.rs # tab bar widget
│ ├── pane.rs # attach pane widget
│ ├── spawn_modal.rs # spawn form
│ ├── help.rs # help overlay
│ ├── render.rs # top-level draw orchestration
│ └── terminal.rs # RAII setup/restore, panic hook
└── tests/
├── integration.rs # in-process daemon
├── snapshots/ # ratatui TestBackend goldens
└── ...
```
@@ -1,131 +0,0 @@
# 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.
@@ -1,107 +0,0 @@
# Spawn form: home-dir prefill + live directory autocomplete (NAP-329)
## Problem
The web spawn form's **Working directory** field is `required` but offers no
help filling it: no reliable prefill, no completion. The user wants
1. the field prefilled with the **selected host's home dir**, updating when the
host changes; and
2. **shell-style directory completion** (`cd dir/sub<tab>`) driven by **real-time
queries to each host's daemon**, so it works across every federated host.
Applies to both the **cwd** field and the worktree **Base repo** field.
## Architecture context
The web SPA talks to a single **home daemon** (served by `maestro-web`, which
reverse-proxies `/api/*`) — or, in dev, directly to the **registry** (vite
proxy). The home daemon owns its sessions and mirrors peers' catalogs; it
**brokers** cross-daemon operations (`create`/`kill`) to the owning daemon over
the registry control link (NAP-327). The registry can also drive a daemon RPC
directly (`create_session`/`kill_session`) for its own `client_api` front door.
Directory listing must reach **the daemon whose host you're spawning on**, which
may be the home daemon itself or any peer. So we mirror the create/kill plumbing
end to end rather than only the registry leg.
## Design
### Listing strategy
The daemon lists **one directory's immediate subdirectories**; the web caches
the result per `(daemonId, dir)` and filters the trailing path segment
client-side, re-querying only when the user crosses a `/`. Fewer round-trips,
instant filtering, still live. Directories only (cwd / base-repo are always
dirs); hidden dirs excluded; sorted case-insensitively; capped.
### Wire protocol (`maestro-protocol`)
- `DirListing { path: PathBuf, entries: Vec<String>, truncated: bool }` — `path`
is the **resolved absolute** dir (after `~`/relative expansion); `entries` are
immediate subdirectory names.
- `DaemonInbound::ListDir { reply_to, path: String }` — registry → owner daemon.
- `DaemonOutbound::ListDirResult { reply_to, result: RpcResult<DirListing> }` —
owner → registry.
- `DaemonOutbound::ForwardListDir { reply_to, daemon_id, path }` — home daemon →
registry (relay to owner).
- `DaemonInbound::ForwardListDirResult { reply_to, result: RpcResult<DirListing> }`
— registry → home daemon.
Carried by the existing `RpcResult` + `#[serde(other)] Unknown` forward-compat
machinery, so older peers degrade gracefully.
### Daemon (`maestro-daemon`)
- New pure module `dirlist`: `list_dir(home_dir, requested) -> Result<DirListing, ErrorInfo>`.
Expansion: empty → home; leading `~` → home; relative → joined onto home.
Reads the dir, keeps directories (resolving symlinks), sorts, caps (`MAX_ENTRIES`).
Error codes: `not_found`, `permission_denied`, `not_a_directory`, `io_error`.
- `registry_client` handles `ListDir` (calls `dirlist::list_dir`, replies
`ListDirResult`) and `ForwardListDirResult` (routes to broker).
- `broker` gains `forward_list_dir(daemon_id, path)` + `complete_forward_list_dir`,
mirroring `forward_kill` (fail-fast while disconnected, RAII pending cleanup).
- `local_api`: `GET /api/daemons/:id/dirs?path=<p>` — self → `dirlist::list_dir`;
peer → `broker.forward_list_dir`. Maps error codes to HTTP status.
### Registry (`maestro-registry`)
- `Registry::list_dir(daemon_id, path)` mirroring `create_session` (new
`pending_list_dir` map + `PendingGuard`, `complete_list_dir`); no catalog
side-effects.
- `forward_list_dir(home, reply_to, target, path)` mirroring `forward_kill`.
- `handler` dispatches `ListDirResult` → `complete_list_dir` and `ForwardListDir`
→ `forward_list_dir`.
- `client_api`: `GET /api/daemons/:id/dirs?path=<p>` → `registry.list_dir`, same
timeout/error mapping as `create_session`.
### Web (`web/src`)
- `types.ts`: `DirListing`.
- `api.ts`: `listDir(daemonId, path) -> Promise<DirListing>` →
`GET /api/daemons/:id/dirs?path=…`.
- New `dirAutocomplete.ts`: attaches to an input + a daemon-id getter. Debounced
fetch of the current dir, `(daemon,dir)` cache, fixed-position dropdown with
arrow-nav; **Tab** completes the longest common prefix / accepts the highlighted
entry; **Enter** accepts (suppressing submit) or submits when closed; **`/`**
drills into the next level; **Esc** closes. Failures degrade to no suggestions.
- `spawn.ts`: keep host-change home-dir prefill; wire the widget onto cwd +
worktree base repo.
## Testing
- Protocol: serde round-trip for the four new variants + `DirListing`, and an
unknown-variant decode (already covered generically).
- Daemon `dirlist`: temp-dir listing (dirs-only, hidden excluded, sorted), `~`
and relative + empty expansion, and `not_found` / `not_a_directory` codes.
- Daemon `broker`: `forward_list_dir` round-trip + fail-fast-while-disconnected,
mirroring the existing forward_create/kill tests.
- Registry: `list_dir` RPC pairing + `complete_list_dir`; `forward_list_dir`
relay, reusing the create/kill in-process channel harness.
- Web: no test runner present; rely on `tsc` typecheck + manual verification.
## Scope
In: the full web→(home daemon|registry)→owner daemon path so completion works
for any host. Out: listing files (dirs only); fuzzy matching (prefix only);
persisting recently-used paths.
@@ -1,314 +0,0 @@
# 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).
+18 -24
View File
@@ -1,7 +1,7 @@
# maestro-web
Browser UI for Maestro. Connects to the registry HTTP/WS API for the catalog
and to individual daemons (via direct WebSocket) for terminal I/O.
Browser UI for Maestro. Talks to a single local daemon over its `/api/*`
HTTP/WS surface — session catalog, spawn/kill, and terminal I/O.
## Framework choice: vanilla TypeScript
@@ -11,17 +11,17 @@ rendering; everything else is a session tree and a spawn form, both trivial
in plain DOM.
This decision is non-architectural — swap in a framework later without
touching the registry or daemon if needed.
touching the daemon if needed.
## Layout
```
src/
main.ts # entry point, hash-route dispatch
api.ts # registry HTTP API client
api.ts # daemon HTTP API client
events.ts # `/api/events` WebSocket subscriber
catalog.ts # session tree view (groups by metadata.label)
attach.ts # xterm.js + daemon WS attach view
catalog.ts # session tree view (groups by working directory)
attach.ts # xterm.js + attach WS view
spawn.ts # spawn-session modal
theme.ts # dark/light theme toggle + persistence
types.ts # mirror of `maestro-protocol` JSON shapes
@@ -39,40 +39,34 @@ follows the OS by default and can be pinned via the header toggle (persisted in
`localStorage`).
`mock.ts` is a DEV-only fixture (stripped from production builds): visit the dev
server with `?mock` to seed a representative catalog without a running registry,
server with `?mock` to seed a representative catalog without a running daemon,
`?mock&spawn` to open the spawn modal, and `?theme=light|dark` to pin a theme.
Routing is hash-based:
- `#/` → catalog
- `#/attach/<session-id>` → terminal
This keeps the registry's static handler dumb (no SPA fallback needed).
Concurrent attaches: open the same `#/attach/<id>` URL in another tab.
This keeps the static handler dumb (no SPA fallback needed). Concurrent
attaches: open the same `#/attach/<id>` URL in another tab.
## Dev
```sh
pnpm install
pnpm dev # http://localhost:5173, proxies /api → http://127.0.0.1:8080
pnpm build # → dist/, embedded into the registry binary at cargo build time
pnpm dev # http://localhost:5173, proxies /api → http://127.0.0.1:7070
pnpm build # → dist/, embedded into the maestro-web binary at cargo build time
```
The Vite dev server proxies `/api` and the events WebSocket to a
`maestro-registry`. Default is `http://127.0.0.1:8080`; point it elsewhere by
exporting `MAESTRO_REGISTRY` before `pnpm dev`:
The Vite dev server proxies `/api` and the WebSockets to a local daemon.
Default is `http://127.0.0.1:7070`; point it elsewhere by exporting
`MAESTRO_DAEMON_URL` before `pnpm dev`:
```sh
MAESTRO_REGISTRY=https://maestro-registry.example.com pnpm dev
# Daemon-facing URL also works — `wss://...` is rewritten to `https://...`
# and a trailing `/daemon` is stripped.
MAESTRO_REGISTRY=wss://maestro-registry.example.com/daemon pnpm dev
MAESTRO_DAEMON_URL=http://laptop.lan:7070 pnpm dev
```
Direct daemon WS connections (for attach) bypass the proxy and use the URL
returned by `GET /api/sessions/:id/connect-info`.
## Production
`pnpm build` writes the bundle to `web/dist/`. The Rust registry crate
embeds that directory via `rust-embed` at compile time and serves it from
`/`. See `crates/maestro-registry/src/web_assets.rs`.
`pnpm build` writes the bundle to `web/dist/`. The `maestro-web` crate embeds
that directory at compile time and serves it from `/`, reverse-proxying `/api/*`
to the daemon. See `crates/maestro-web/src/static_bundle.rs`.
+15 -22
View File
@@ -7,7 +7,6 @@ import type {
RecentDirs,
Session,
Uuid,
WebInfo,
} from "./types.js";
async function jsonOrError<T>(res: Response): Promise<T> {
@@ -25,10 +24,6 @@ async function jsonOrError<T>(res: Response): Promise<T> {
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
export async function listDaemons(): Promise<DaemonInfo[]> {
return jsonOrError(await fetch("/api/daemons", { credentials: "same-origin" }));
}
export async function listSessions(): Promise<Session[]> {
return jsonOrError(await fetch("/api/sessions", { credentials: "same-origin" }));
}
@@ -53,31 +48,29 @@ export async function killSession(id: Uuid): Promise<void> {
);
}
/** List one directory's immediate subdirectories on `daemonId`'s host (NAP-329).
* `path` may be empty, `~`, relative, or absolute — the daemon resolves it
* against its own home dir. */
export async function listDir(daemonId: string, path: string): Promise<DirListing> {
const url = `/api/daemons/${encodeURIComponent(daemonId)}/dirs?path=${encodeURIComponent(path)}`;
/** List one directory's immediate subdirectories (NAP-329). `path` may be empty,
* `~`, relative, or absolute — the daemon resolves it against its home dir. */
export async function listDir(path: string): Promise<DirListing> {
const url = `/api/dirs?path=${encodeURIComponent(path)}`;
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
/** Identity + spawn presets of the daemon this web app is connected to (#26). */
export async function getWebInfo(): Promise<WebInfo> {
return jsonOrError(await fetch("/api/web/info", { credentials: "same-origin" }));
export async function getDaemonInfo(): Promise<DaemonInfo> {
return jsonOrError(await fetch("/api/info", { credentials: "same-origin" }));
}
/** Recently-used working dirs on `daemonId`'s host, newest-first and deduped by
* dir (#26). Drives the spawn form's recents picker. */
export async function recentDirs(daemonId: string): Promise<RecentDirs> {
const url = `/api/daemons/${encodeURIComponent(daemonId)}/recent-dirs`;
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
/** Recently-used working dirs, newest-first and deduped by dir (#26). Drives the
* spawn form's recents picker. */
export async function recentDirs(): Promise<RecentDirs> {
return jsonOrError(await fetch("/api/recent-dirs", { credentials: "same-origin" }));
}
/** Probe the enclosing git repo of `path` on `daemonId`'s host (#26). `root` is
* absent when the path isn't inside a repo. `path` may be `~`/relative/absolute;
* the daemon resolves it against its home dir. */
export async function gitRoot(daemonId: string, path: string): Promise<GitRoot> {
const url = `/api/daemons/${encodeURIComponent(daemonId)}/gitroot?path=${encodeURIComponent(path)}`;
/** Probe the enclosing git repo of `path` (#26). `root` is absent when the path
* isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves
* it against its home dir. */
export async function gitRoot(path: string): Promise<GitRoot> {
const url = `/api/gitroot?path=${encodeURIComponent(path)}`;
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
+94 -16
View File
@@ -5,7 +5,7 @@ import "@xterm/xterm/css/xterm.css";
import type { ClientControl, Uuid } from "./types.js";
/**
* A single live attach: an xterm terminal bound to a daemon-broker WebSocket.
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
*
* The connection is **decoupled from the UI**: it owns a long-lived WS that
* keeps streaming PTY output into the terminal buffer whether or not the view
@@ -36,12 +36,11 @@ export interface AttachOptions {
onClosed?: (sessionId: Uuid) => void;
}
/** What the header shows about the session (name / command / host). Optional —
* falls back to a short session id when the session isn't known yet. */
/** What the header shows about the session (name / command). Optional — falls
* back to a short session id when the session isn't known yet. */
export interface AttachInfo {
name: string;
command: string;
daemon: string;
}
function esc(s: string): string {
@@ -53,12 +52,11 @@ function esc(s: string): string {
}
/**
* Same-origin broker URL. The browser loads from `maestro-web`, which
* Same-origin attach URL. The browser loads from `maestro-web`, which
* reverse-proxies this WebSocket to the co-located daemon's `/api/attach/:id`
* broker endpoint (the daemon short-circuits to the local PTY when it owns the
* session, or brokers to the owning peer — NAP-247/NAP-327).
* endpoint.
*/
function brokerStreamUrl(sessionId: Uuid): string {
function attachStreamUrl(sessionId: Uuid): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/api/attach/${encodeURIComponent(sessionId)}`;
}
@@ -70,18 +68,15 @@ export function createAttachConnection(
): AttachConnection {
const el = document.createElement("div");
el.className = "attach";
// Useful identity over a bare UUID: name · command @ host. The full id stays
// in the tooltip for when you really need it.
// Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it.
const titleHtml = info
? `<span class="at-name">${esc(info.name)}</span>` +
(info.command && info.command !== info.name
? `<span class="at-cmd">${esc(info.command)}</span>`
: "") +
(info.daemon ? `<span class="at-host">${esc(info.daemon)}</span>` : "")
: "")
: `session <span class="sid">${esc(sessionId.slice(0, 8))}</span>`;
const titleAttr = info
? `${info.name} · ${info.command} @ ${info.daemon} (${sessionId})`
: sessionId;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = `
<header class="attach-header">
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog" title="Back to catalog">←</a>
@@ -130,6 +125,78 @@ export function createAttachConnection(
term.loadAddon(fit);
term.open(body);
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
// • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
// scrolling *its* content (the only scroll that exists on the alt screen).
// • alt screen with no mouse tracking → nothing to do; the app owns input.
// `touch-action: pan-y` (CSS) tells the browser we handle the Y gesture.
{
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
const cellH = () => {
const core = (term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } };
})._core;
return core?._renderService?.dimensions?.css?.cell?.height || 16;
};
if (vp) {
let lastY = 0;
let lastX = 0;
let remainder = 0;
vp.addEventListener(
"touchstart",
(e) => {
if (e.touches.length !== 1) return;
lastY = e.touches[0].clientY;
lastX = e.touches[0].clientX;
remainder = 0;
},
{ passive: true },
);
vp.addEventListener(
"touchmove",
(e) => {
if (e.touches.length !== 1) return;
const ch = cellH();
const y = e.touches[0].clientY;
const px = lastY - y + remainder; // finger up → scroll toward newer
lastY = y;
const lines = Math.trunc(px / ch);
remainder = px - lines * ch;
if (lines === 0) return;
// term.modes.mouseTrackingMode is "none" unless the app turned mouse
// reporting on — that's the signal to forward instead of scrollback.
const mouseTracked = term.modes.mouseTrackingMode !== "none";
if (mouseTracked && screen) {
// One wheel event per crossed cell, dispatched at the touch point so
// xterm computes valid cell coords for the mouse report.
const dir = lines > 0 ? 1 : -1;
for (let i = 0; i < Math.abs(lines); i++) {
screen.dispatchEvent(
new WheelEvent("wheel", {
deltaY: dir * ch,
deltaMode: 0,
clientX: lastX,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
}
e.preventDefault();
} else if (term.buffer.active.type !== "alternate") {
term.scrollLines(lines);
e.preventDefault();
}
},
{ passive: false },
);
}
}
// Mobile: the special-key bar is only useful while the soft keyboard is up,
// so reveal it only when the terminal's hidden textarea is focused (CSS still
// gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown
@@ -197,7 +264,7 @@ export function createAttachConnection(
}
};
const ws = new WebSocket(brokerStreamUrl(sessionId));
const ws = new WebSocket(attachStreamUrl(sessionId));
ws.binaryType = "arraybuffer";
const sendControl = (msg: ClientControl) => {
@@ -245,6 +312,17 @@ export function createAttachConnection(
};
ws.onclose = (ev) => {
// The web proxy relays a daemon rejection (unknown session) as an
// application close code (4000+) carrying a human reason. Show it in the pane — both the header and the terminal body
// — and KEEP the pane up so the user can read why, instead of letting it
// flash and vanish (#39). Clean session-ends and ordinary drops keep the
// auto-close behavior below.
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason && !disposed) {
setState(reason, "error");
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
return;
}
setState(`closed (${ev.code})`, "closed");
// Only bubble up server/network closes — an intentional dispose has already
// set `disposed`, and eviction/user-close handle their own pane teardown.
+47 -117
View File
@@ -1,8 +1,8 @@
import { killSession } from "./api.js";
import { getDaemonInfo, killSession } from "./api.js";
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { DaemonInfo, Session } from "./types.js";
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
import type { Session } from "./types.js";
import { abbreviateCwd, abbreviateFolderName, sessionCwd, sessionName } from "./types.js";
import { openSpawnModal } from "./spawn.js";
import { createThemeToggle } from "./theme.js";
@@ -29,26 +29,18 @@ function compareNodes(a: TreeNode, b: TreeNode): number {
return an.localeCompare(bn);
}
function buildTree(
sessions: Session[],
daemons: Map<string, DaemonInfo>,
filter: string,
): TreeNode {
function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode {
const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] };
const lc = filter.trim().toLowerCase();
for (const s of sessions) {
const home = daemons.get(s.daemon_id)?.home_dir;
const cwd = sessionCwd(s);
const dir = cwd ? abbreviateCwd(cwd, home) : "";
if (lc) {
const haystack = `${s.daemon_id} ${sessionName(s)} ${dir}`.toLowerCase();
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
if (!haystack.includes(lc)) continue;
}
// Host is the first level of nesting; the working directory nests beneath
// it, so the daemon no longer needs to ride along on each leaf.
const dirParts = dir ? dir.split("/").filter(Boolean) : [];
const parts = [s.daemon_id, ...dirParts];
const parts = dir ? dir.split("/").filter(Boolean) : [];
let cursor = root;
for (const part of parts) {
const path = cursor.path ? `${cursor.path}/${part}` : part;
@@ -69,16 +61,9 @@ function buildTree(
for (const c of n.children) if (c.kind === "folder") sortRec(c);
};
sortRec(root);
// Collapse linear directory chains *within* each daemon (but never the daemon
// node itself) so `~/git.naps.pt/naps62/maestro` reads as one compact folder
// rather than four nested ones.
for (const daemon of root.children) {
if (daemon.kind === "folder") {
daemon.children = daemon.children.map((c) =>
c.kind === "folder" ? mergeLinear(c) : c,
);
}
}
// Collapse linear directory chains so `~/git.naps.pt/naps62/maestro` reads as
// one compact folder rather than four nested ones.
root.children = root.children.map((c) => (c.kind === "folder" ? mergeLinear(c) : c));
return root;
}
@@ -119,7 +104,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
// A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading
// segments to initials (`~/g/n/maestro`) so the dir name stays readable in
// a narrow sidebar; the full path lives in the tooltip.
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name" title="${escapeHtml(node.name)}">${abbreviateFolderName(node.name)}</span><span class="folder-count">${countLeaves(node)}</span>`;
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name" title="${escapeHtml(node.name)}">${abbreviateFolderName(node.name)}</span>`;
header.addEventListener("click", () => {
if (collapsed.has(node.path)) collapsed.delete(node.path);
else collapsed.add(node.path);
@@ -145,11 +130,8 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, on daemon ${s.daemon_id}`,
);
// The host is now the enclosing folder, so only surface the command — and
row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`);
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
row.innerHTML = `
@@ -165,8 +147,6 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
// The sidebar (which has the daemon list) opens the spawn modal pre-seeded
// from this session.
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
@@ -183,26 +163,6 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
return row;
}
function countLeaves(node: TreeNode): number {
if (node.kind === "leaf") return 1;
return node.children.reduce((n, c) => n + countLeaves(c), 0);
}
/** `~/git.naps.pt/naps62/maestro` → `~/g/n/maestro`: keep the last segment
* verbatim, collapse every earlier one to its first character. Two-or-fewer
* segments are left alone (nothing to gain). Returns escaped HTML with the
* collapsed stem wrapped so CSS can dim it. */
function abbreviateFolderName(name: string): string {
const parts = name.split("/");
if (parts.length <= 2) return escapeHtml(name);
const last = parts[parts.length - 1];
const stem = parts
.slice(0, -1)
.map((p) => (p === "~" ? "~" : Array.from(p)[0] ?? ""))
.join("/");
return `<span class="folder-stem">${escapeHtml(stem)}/</span>${escapeHtml(last)}`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
@@ -211,42 +171,19 @@ function escapeHtml(s: string): string {
.replace(/"/g, "&quot;");
}
function renderStatusbar(daemons: DaemonInfo[], sessions: Session[]): HTMLElement {
function renderStatusbar(sessions: Session[]): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "statusbar";
// Per-host session counts replace the old global "N sessions / N live" tally:
// each daemon chip carries its own green count, so the tally was redundant.
const counts = new Map<string, number>();
for (const s of sessions) {
counts.set(s.daemon_id, (counts.get(s.daemon_id) ?? 0) + 1);
}
const daemonsWrap = document.createElement("div");
daemonsWrap.className = "daemons";
if (daemons.length === 0) {
daemonsWrap.innerHTML = `<span class="daemon-empty">no daemons connected</span>`;
} else {
for (const d of daemons) {
const n = counts.get(d.daemon_id) ?? 0;
const chip = document.createElement("span");
chip.className = "daemon-chip";
chip.title = `${d.hostname} · ${d.daemon_url} · ${n} session${n === 1 ? "" : "s"}`;
chip.innerHTML =
`<span class="daemon-name">${escapeHtml(d.daemon_id)}</span>` +
`<b class="daemon-count${n === 0 ? " is-empty" : ""}">${n}</b>`;
daemonsWrap.appendChild(chip);
}
}
wrap.appendChild(daemonsWrap);
const live = sessions.filter((s) => s.status === "Running").length;
const tally = document.createElement("span");
tally.className = "session-tally";
tally.textContent = `${sessions.length} session${sessions.length === 1 ? "" : "s"} · ${live} live`;
wrap.appendChild(tally);
return wrap;
}
function renderEmpty(
noDaemons: boolean,
filtering: boolean,
onSpawn: () => void,
): HTMLElement {
function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
const el = document.createElement("div");
el.className = "empty";
if (filtering) {
@@ -256,14 +193,6 @@ function renderEmpty(
<p class="empty-hint">No sessions match your search. Clear it to see everything.</p>`;
return el;
}
if (noDaemons) {
el.classList.add("connecting");
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">◴</div>
<div class="empty-title">Waiting for a daemon</div>
<p class="empty-hint">No daemons are connected yet. Start a <code>maestro daemon</code> on a host and it will appear here.</p>`;
return el;
}
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">+</div>
<div class="empty-title">No sessions yet</div>
@@ -302,10 +231,11 @@ function createDebugToggle(): HTMLButtonElement {
return btn;
}
export function renderTopbar(store: CatalogStore): HTMLElement {
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
const header = document.createElement("header");
header.className = "app-header";
header.innerHTML = `
<button type="button" class="app-menu" title="Sessions" aria-label="Toggle session list">☰</button>
<a class="brand" href="#/" aria-label="maestro — clear selection">
<span class="brand-dot" aria-hidden="true"></span>
<h1>maestro<span class="blink" aria-hidden="true">_</span></h1>
@@ -313,14 +243,16 @@ export function renderTopbar(store: CatalogStore): HTMLElement {
<div class="app-status"></div>
<div class="app-actions"></div>
`;
// Mobile-only ☰ (CSS hides it on desktop) — opens the off-canvas session drawer.
(header.querySelector(".app-menu") as HTMLElement).addEventListener("click", () =>
onToggleSidebar?.(),
);
// Session tally + daemon chips live centered in the header (not the sidebar).
// Session tally lives centered in the header (not the sidebar).
const statusHost = header.querySelector(".app-status") as HTMLElement;
const renderStatus = () => {
const state = store.snapshot();
statusHost.replaceChildren(
renderStatusbar([...state.daemons.values()], [...state.sessions.values()]),
);
statusHost.replaceChildren(renderStatusbar([...state.sessions.values()]));
};
store.subscribe(renderStatus);
renderStatus();
@@ -342,10 +274,7 @@ export function renderTopbar(store: CatalogStore): HTMLElement {
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
spawnBtn.addEventListener("click", () => {
const snap = store.snapshot();
openSpawnModal(snap.daemons, undefined, snap.sessions);
});
spawnBtn.addEventListener("click", () => openSpawnModal());
actions.appendChild(spawnBtn);
return header;
}
@@ -374,7 +303,7 @@ export interface SidebarHandle {
/**
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
* live daemon/session status line, and the folder-grouped session tree. Unlike
* live session status line, and the folder-grouped session tree. Unlike
* the old full-screen catalog this is mounted once by the shell and stays put
* while the content pane swaps between sessions — so the store subscription and
* scroll position survive navigation. The active session is highlighted.
@@ -389,6 +318,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const el = document.createElement("aside");
el.className = "sidebar";
// The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree
// renders with absolute paths until it lands.
let homeDir: string | undefined;
const searchHeader = document.createElement("header");
searchHeader.className = "sidebar-search";
searchHeader.innerHTML = `
@@ -489,14 +422,11 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const refresh = () => {
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, state.daemons, filter);
const tree = buildTree(sessions, homeDir, filter);
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(
renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () => {
const snap = store.snapshot();
openSpawnModal(snap.daemons, undefined, snap.sessions);
}),
renderEmpty(filter.trim().length > 0, () => openSpawnModal()),
);
return;
}
@@ -506,24 +436,24 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyHighlights();
};
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir;
refresh();
})
.catch(() => {
/* absolute paths are a fine fallback */
});
const rerenderHandler = () => refresh();
document.addEventListener("catalog-rerender", rerenderHandler);
// Spawn similar: reopen the spawn modal pre-seeded from a session's host, dir,
// and command (matched back to a preset by the form) — but with a freshly
// Spawn similar: reopen the spawn modal pre-seeded from a session's dir and
// command (matched back to a preset by the form) — but with a freshly
// generated name rather than the original's, since it's a new session.
const spawnSimilarHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
const snap = store.snapshot();
openSpawnModal(
snap.daemons,
{
daemon_id: s.daemon_id,
cwd: s.cwd,
command: s.command,
},
snap.sessions,
);
openSpawnModal({ cwd: s.cwd, command: s.command });
};
document.addEventListener("catalog-spawn-similar", spawnSimilarHandler);
Binary file not shown.
+7 -19
View File
@@ -1,15 +1,14 @@
import { eventsWsUrl, listDaemons, listSessions } from "./api.js";
import type { DaemonInfo, RegistryEvent, Session, Uuid } from "./types.js";
import { eventsWsUrl, listSessions } from "./api.js";
import type { CatalogEvent, Session, Uuid } from "./types.js";
export interface CatalogState {
daemons: Map<string, DaemonInfo>;
sessions: Map<Uuid, Session>;
}
export type CatalogListener = (state: CatalogState) => void;
export class CatalogStore {
private state: CatalogState = { daemons: new Map(), sessions: new Map() };
private state: CatalogState = { sessions: new Map() };
private listeners = new Set<CatalogListener>();
private ws: WebSocket | null = null;
private reconnectTimer: number | null = null;
@@ -46,9 +45,9 @@ export class CatalogStore {
this.ws = ws;
ws.onmessage = (ev) => {
let event: RegistryEvent;
let event: CatalogEvent;
try {
event = JSON.parse(ev.data) as RegistryEvent;
event = JSON.parse(ev.data) as CatalogEvent;
} catch {
return;
}
@@ -83,23 +82,13 @@ export class CatalogStore {
}, delay);
}
private apply(event: RegistryEvent): void {
private apply(event: CatalogEvent): void {
switch (event.type) {
case "Snapshot":
this.state = {
daemons: new Map(event.daemons.map((d) => [d.daemon_id, d])),
sessions: new Map(event.sessions.map((s) => [s.id, s])),
};
break;
case "DaemonAdded":
this.state.daemons.set(event.daemon.daemon_id, event.daemon);
break;
case "DaemonRemoved":
this.state.daemons.delete(event.daemon_id);
for (const [id, sess] of this.state.sessions) {
if (sess.daemon_id === event.daemon_id) this.state.sessions.delete(id);
}
break;
case "SessionAdded":
this.state.sessions.set(event.session.id, event.session);
break;
@@ -119,9 +108,8 @@ export class CatalogStore {
private async resync(): Promise<void> {
try {
const [daemons, sessions] = await Promise.all([listDaemons(), listSessions()]);
const sessions = await listSessions();
this.state = {
daemons: new Map(daemons.map((d) => [d.daemon_id, d])),
sessions: new Map(sessions.map((s) => [s.id, s])),
};
this.emit();
-1
View File
@@ -15,7 +15,6 @@ import { sessionName, type Session, type Uuid } from "./types.js";
* them. Everything else on the Session is shown, including future fields. */
const CLIENT_OWNED = new Set([
"id",
"daemon_id",
"cwd",
"command",
"env",
+13 -23
View File
@@ -55,7 +55,6 @@ function folderHeader(name: string, count: number, depth: number): HTMLElement {
function leafRow(opts: {
name: string;
daemon: string;
cmd: string;
status: "running" | "dead";
active?: boolean;
@@ -192,8 +191,7 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
body.append(
specimen("daemon-chip", h("span", "daemon-chip", "thinkpad")),
specimen("daemon-empty", h("span", "daemon-empty", "no daemons connected")),
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
specimen("folder-count", h("span", "folder-count", "4")),
specimen("leaf-badge", h("span", "leaf-badge", "exited")),
specimen(
@@ -217,23 +215,20 @@ export function renderKit(): HTMLElement {
{
const { el, body } = section(
"Session rows — nested by host, then directory",
"Host is the first level of nesting; the working directory (home collapsed to ~) nests beneath it, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
"The working directory (home collapsed to ~) is the nesting, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
);
const list = h("div", "kit-tree");
list.append(
folderHeader("thinkpad", 3, 0),
folderHeader("~/git/maestro", 3, 1),
folderHeader("web", 2, 2),
leafRow({ name: "redesign", daemon: "", cmd: "claude", status: "running", indent: 3 }),
leafRow({ name: "dev-server", daemon: "", cmd: "vite dev", status: "running", indent: 3, active: true }),
folderHeader("crates/registry", 1, 2),
leafRow({ name: "registry-broker", daemon: "", cmd: "claude", status: "running", indent: 3 }),
folderHeader("tower", 2, 0),
folderHeader("~/work/acme", 1, 1),
leafRow({ name: "billing-api", daemon: "", cmd: "claude", status: "running", indent: 2 }),
leafRow({ name: "scratch", daemon: "", cmd: "bash", status: "dead", indent: 1 }),
folderHeader("pi", 1, 0),
leafRow({ name: "monitor", daemon: "", cmd: "btop", status: "running", indent: 1 }),
folderHeader("~/git/maestro", 3, 0),
folderHeader("web", 2, 1),
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
leafRow({ name: "dev-server", cmd: "vite dev", status: "running", indent: 2, active: true }),
folderHeader("crates/daemon", 1, 1),
leafRow({ name: "daemon-work", cmd: "claude", status: "running", indent: 2 }),
folderHeader("~/work/acme", 2, 0),
leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }),
leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }),
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
);
body.classList.add("kit-tree-host");
body.appendChild(list);
@@ -258,12 +253,7 @@ export function renderKit(): HTMLElement {
<p class="empty-hint">Start your first session — it'll show up here.</p>
<div class="empty-actions"></div>`;
noSessions.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
const waiting = h("div", "empty connecting");
waiting.innerHTML = `
<div class="empty-glyph" aria-hidden="true">◴</div>
<div class="empty-title">Waiting for a daemon</div>
<p class="empty-hint">Start a <code>maestro daemon</code> on a host and it will appear here.</p>`;
body.append(welcome, noSessions, waiting);
body.append(welcome, noSessions);
main.appendChild(el);
}
+45 -9
View File
@@ -50,7 +50,7 @@ const store = new CatalogStore();
store.start();
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
// without a running registry. Stripped from production builds by Vite.
// without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) {
void import("./mock.js").then((m) => m.seedMock(store));
}
@@ -72,13 +72,16 @@ const content = document.createElement("section");
content.className = "content";
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
// the tab bar; a backdrop covers the content while it's open, and tapping a
// the header; a backdrop covers the content while it's open, and tapping a
// session closes it. On desktop these are inert (CSS gates the drawer styling).
const backdrop = document.createElement("div");
backdrop.className = "mv-backdrop";
function closeDrawer(): void {
shellEl.classList.remove("drawer-open");
}
function openDrawer(): void {
shellEl.classList.add("drawer-open");
}
function toggleDrawer(): void {
shellEl.classList.toggle("drawer-open");
}
@@ -87,8 +90,45 @@ sidebar.el.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) closeDrawer();
});
// Mobile: horizontal swipes drive the drawer (right opens, left closes) rather
// than the browser's back/forward navigation. We only act on a clearly
// horizontal gesture past a threshold, so vertical terminal scrolling (which
// owns its own touch handler) is untouched.
{
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
const open = shellEl.classList.contains("drawer-open");
if (dx > 0 && !open) openDrawer();
else if (dx < 0 && open) closeDrawer();
},
{ passive: true },
);
}
shellEl.append(sidebar.el, content, backdrop);
root.replaceChildren(renderTopbar(store), shellEl);
root.replaceChildren(renderTopbar(store, toggleDrawer), shellEl);
// The pool owns attach connections independently of the route: navigating away
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
@@ -136,7 +176,7 @@ function scheduleSave(): void {
function infoFor(sessionId: string) {
const s = store.snapshot().sessions.get(sessionId);
return s ? { name: sessionName(s), command: s.command, daemon: s.daemon_id } : undefined;
return s ? { name: sessionName(s), command: s.command } : undefined;
}
/** Drive the shell's responsive layout off whether any pane is open. */
@@ -160,10 +200,7 @@ function renderWelcome(): HTMLElement {
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.addEventListener("click", () => {
const snap = store.snapshot();
openSpawnModal(snap.daemons, undefined, snap.sessions);
});
spawn.addEventListener("click", () => openSpawnModal());
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
@@ -188,7 +225,6 @@ mv = createMultiView({
onDisconnect: closePane,
renderWelcome,
onLayoutChange: scheduleSave,
onToggleSidebar: toggleDrawer,
});
let contentShowsMv = false;
function showMultiView(): void {
+19 -35
View File
@@ -2,7 +2,7 @@
// main.ts (`?mock`), so it never ships in a production bundle. It pushes a
// synthetic Snapshot through the store's normal event path.
import type { CatalogStore } from "./events.js";
import type { ActivityState, Session, DaemonInfo } from "./types.js";
import type { ActivityState, Session } from "./types.js";
// Rotate inferred activity across the fixture so the debug HUD (#20) shows all
// three states. Keyed off the id's last hex digit for stable variety.
@@ -10,7 +10,6 @@ const ACTIVITIES: ActivityState[] = ["Active", "Idle", "AwaitingInput"];
function sess(
id: string,
daemon: string,
command: string,
status: "Running" | "Dead",
cwd: string,
@@ -19,7 +18,6 @@ function sess(
const seed = parseInt(id.slice(-1), 16) || 0;
return {
id,
daemon_id: daemon,
cwd,
command,
env: {},
@@ -48,13 +46,12 @@ function stubSpawnApi(): void {
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
if (path.startsWith("/api/web/info")) {
if (path.startsWith("/api/info")) {
return Promise.resolve(
json({
daemon_id: "thinkpad",
hostname: "thinkpad.lan",
version: "0.1.0",
daemon_url: "wss://thinkpad.lan/daemon",
home_dir: "/home/dev",
presets: [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
@@ -87,38 +84,31 @@ function stubSpawnApi(): void {
}
export function seedMock(store: CatalogStore): void {
const daemons: DaemonInfo[] = [
{ daemon_id: "thinkpad", hostname: "thinkpad.lan", version: "0.1.0", daemon_url: "wss://thinkpad.lan/daemon", home_dir: "/home/dev" },
{ daemon_id: "tower", hostname: "tower.lan", version: "0.1.0", daemon_url: "wss://tower.lan/daemon", home_dir: "/home/dev" },
{ daemon_id: "pi", hostname: "pi.lan", version: "0.1.0", daemon_url: "wss://pi.lan/daemon", home_dir: "/home/pi" },
];
const sessions: Session[] = [
// ~/git/maestro/web/* — deep dir chain; "~/git/maestro" collapses to one node.
sess("a1b2c3d4-0000-0000-0000-000000000001", "thinkpad", "claude", "Running", "/home/dev/git/maestro/web", "redesign"),
sess("a1b2c3d4-0000-0000-0000-000000000002", "thinkpad", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"),
sess("a1b2c3d4-0000-0000-0000-000000000003", "tower", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "thinkpad", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"),
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign"),
sess("a1b2c3d4-0000-0000-0000-000000000002", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"),
sess("a1b2c3d4-0000-0000-0000-000000000003", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"),
// ~/git/maestro/crates/* — sibling subtree under the same repo.
sess("a1b2c3d4-0000-0000-0000-000000000005", "thinkpad", "claude", "Running", "/home/dev/git/maestro/crates/registry", "registry-broker"),
sess("a1b2c3d4-0000-0000-0000-000000000006", "tower", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/registry", "test-loop"),
sess("a1b2c3d4-0000-0000-0000-000000000007", "tower", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"),
sess("a1b2c3d4-0000-0000-0000-000000000005", "claude", "Running", "/home/dev/git/maestro/crates/daemon", "daemon-work"),
sess("a1b2c3d4-0000-0000-0000-000000000006", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/daemon", "test-loop"),
sess("a1b2c3d4-0000-0000-0000-000000000007", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"),
// ~/work/acme/* — a separate top-level dir on the same host.
sess("a1b2c3d4-0000-0000-0000-000000000008", "tower", "claude", "Running", "/home/dev/work/acme/api", "billing-api"),
sess("a1b2c3d4-0000-0000-0000-000000000009", "tower", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"),
sess("a1b2c3d4-0000-0000-0000-000000000008", "claude", "Running", "/home/dev/work/acme/api", "billing-api"),
sess("a1b2c3d4-0000-0000-0000-000000000009", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"),
// ~/git/dotfiles — single session, fully collapsed chain.
sess("a1b2c3d4-0000-0000-0000-000000000010", "tower", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"),
// pi's home (different home_dir) — sits directly under ~.
sess("a1b2c3d4-0000-0000-0000-000000000011", "pi", "btop", "Running", "/home/pi", "monitor"),
sess("a1b2c3d4-0000-0000-0000-000000000010", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"),
sess("a1b2c3d4-0000-0000-0000-000000000011", "btop", "Running", "/home/dev/git/infra", "monitor"),
// outside any home dir → shown verbatim (no ~ collapse).
sess("a1b2c3d4-0000-0000-0000-000000000012", "pi", "ssh gateway", "Dead", "/etc"),
// home root on thinkpad — a leaf directly under ~ (name from command).
sess("a1b2c3d4-0000-0000-0000-000000000013", "thinkpad", "htop", "Running", "/home/dev"),
sess("a1b2c3d4-0000-0000-0000-000000000012", "ssh gateway", "Dead", "/etc"),
// home root — a leaf directly under ~ (name from command).
sess("a1b2c3d4-0000-0000-0000-000000000013", "htop", "Running", "/home/dev"),
];
// The store applies RegistryEvents internally; feed a Snapshot the same way
// The store applies CatalogEvents internally; feed a Snapshot the same way
// the WS would. `apply` is private, so reach it through an index cast.
(store as unknown as { apply: (e: unknown) => void }).apply({
type: "Snapshot",
daemons,
sessions,
});
@@ -126,12 +116,6 @@ export function seedMock(store: CatalogStore): void {
// backend endpoints stubbed so presets/recents/gitroot all render.
if (location.search.includes("spawn")) {
stubSpawnApi();
void import("./spawn.js").then((m) =>
m.openSpawnModal(
new Map(daemons.map((d) => [d.daemon_id, d])),
undefined,
new Map(sessions.map((s) => [s.id, s])),
),
);
void import("./spawn.js").then((m) => m.openSpawnModal());
}
}
+3 -10
View File
@@ -36,8 +36,6 @@ export interface MultiViewOptions {
/** Called after any layout change (structure, focus, or divider resize) so the
* shell can persist it (issue #34). */
onLayoutChange?: () => void;
/** Toggle the mobile sidebar drawer (the ☰ button in the tab bar, issue #35). */
onToggleSidebar?: () => void;
}
export interface MultiView {
@@ -251,14 +249,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
function renderTabs(): void {
tabBar.replaceChildren();
// Mobile-only ☰ to open the sidebar drawer (CSS hides it on desktop).
const menu = document.createElement("button");
menu.type = "button";
menu.className = "mv-mobile-menu";
menu.title = "Sessions";
menu.textContent = "☰";
menu.addEventListener("click", () => opts.onToggleSidebar?.());
tabBar.appendChild(menu);
for (const t of layout.tabs) {
const tab = document.createElement("button");
tab.type = "button";
@@ -344,6 +334,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
// Mobile single-tab: the tab bar carries no information (one tab, one pane),
// so CSS collapses it and we float a ☰ instead (rendered in renderMobile).
el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1);
if (!tab.root) {
pool.setVisible([]);
+2 -3
View File
@@ -1,7 +1,6 @@
// Client-side session-handle generator for the spawn form's instant reroll
// (#23/#27). Mirrors the wordlists in `crates/maestro-names/src/lib.rs` verbatim
// — the Rust generator is the source of truth, but the web wants a zero-round-
// trip reroll, so the lists are duplicated here. Keep them in sync.
// (#23/#27). The spawn form rerolls without a round trip, so the wordlists
// live here; nothing server-side generates handles.
/** Musical adjectives — tempo, dynamics, and articulation markings. */
const ADJECTIVES = [
+1 -1
View File
@@ -4,7 +4,7 @@
// anything malformed — a corrupt or stale blob falls back to a single empty tab.
//
// Sessions that no longer exist when a layout is restored are not pruned here
// (the session list isn't known yet at boot); their panes attach, the broker
// (the session list isn't known yet at boot); their panes attach, the daemon
// closes the dead socket, and the pane auto-closes (see ConnectionPool.onClosed).
import { freshId, type Layout, type Node, type Tab } from "./layout.js";
+23 -134
View File
@@ -1,24 +1,18 @@
import { createSession, getWebInfo, recentDirs, gitRoot } from "./api.js";
import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js";
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
import { generateName } from "./names.js";
import type {
CreateSessionRequest,
DaemonInfo,
RecentDir,
Session,
SpawnPreset,
} from "./types.js";
import type { CreateSessionRequest, RecentDir, SpawnPreset } from "./types.js";
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
/** Seed values for the spawn form — used by "clone" to reopen the dialog with
* the same daemon, cwd, command, and name as an existing session. */
* the same cwd, command, and name as an existing session. */
export interface SpawnPrefill {
daemon_id?: string;
cwd?: string;
command?: string;
name?: string;
}
/** Built-ins used when the daemon's `/api/web/info` presets can't be fetched
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */
const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "claude", label: "Claude", command: "claude" },
@@ -27,14 +21,7 @@ const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "custom", label: "Custom…", command: "" },
];
/** How many host tiles to show before collapsing the rest behind "More…". */
const MAX_HOST_TILES = 4;
export function openSpawnModal(
daemons: Map<string, DaemonInfo>,
prefill?: SpawnPrefill,
sessions?: Map<string, Session>,
): void {
export function openSpawnModal(prefill?: SpawnPrefill): void {
const existing = document.getElementById("modal-host");
if (existing) existing.remove();
@@ -48,10 +35,6 @@ export function openSpawnModal(
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
</header>
<form class="modal-body" id="spawn-form">
<div class="field">
<span class="field-label">Host</span>
<div class="tile-row" id="host-tiles"></div>
</div>
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row" id="cmd-tiles"></div>
@@ -105,7 +88,6 @@ export function openSpawnModal(
const form = host.querySelector("#spawn-form") as HTMLFormElement;
const errEl = form.querySelector(".form-error") as HTMLElement;
const hostTiles = host.querySelector("#host-tiles") as HTMLElement;
const cmdTiles = host.querySelector("#cmd-tiles") as HTMLElement;
const cmdRaw = host.querySelector("#cmd-raw") as HTMLInputElement;
const recentsEl = host.querySelector("#recents") as HTMLElement;
@@ -119,14 +101,13 @@ export function openSpawnModal(
const nameInput = host.querySelector("input[name=name]") as HTMLInputElement;
// ---- form state ------------------------------------------------------
let selectedHostId = "";
let homeDir = "";
let presets: SpawnPreset[] = FALLBACK_PRESETS;
let selectedPresetId = "";
let command = ""; // resolved command (preset's, or the raw custom input)
let cwd = ""; // chosen working directory
let inferredBaseRepo: string | null = null; // from the gitroot probe
let probeToken = 0; // guards async gitroot probes against races
let showAllHosts = false;
// A clone's cwd, applied once the (async) recents load for the initial host
// settles — so the fetch can't race past and clobber the seeded directory.
let cloneCwd: string | null = prefill?.cwd ?? null;
@@ -186,11 +167,12 @@ export function openSpawnModal(
}
});
// Presets ride `/api/web/info`; render fallbacks immediately, then refine.
// Presets ride `/api/info`; render fallbacks immediately, then refine.
renderCommandTiles();
applyInitialCommand();
getWebInfo()
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir ?? "";
if (info.presets?.length) {
presets = info.presets;
renderCommandTiles();
@@ -259,7 +241,7 @@ export function openSpawnModal(
recentsEl.replaceChildren(loadingRow());
let dirs: RecentDir[] = [];
try {
dirs = (await recentDirs(selectedHostId)).dirs ?? [];
dirs = (await recentDirs()).dirs ?? [];
} catch {
dirs = [];
}
@@ -271,13 +253,12 @@ export function openSpawnModal(
recentsEl.appendChild(otherDirRow());
if (cloneCwd) {
// A clone seeds the directory verbatim into the raw input, regardless of
// this host's history.
// the recorded history.
showRawCwd(cloneCwd);
cloneCwd = null;
} else if (dirs.length === 0) {
// No history: default to raw input prefilled with the host's home dir.
const home = daemons.get(selectedHostId)?.home_dir ?? "";
showRawCwd(home);
// No history: default to raw input prefilled with the daemon's home dir.
showRawCwd(homeDir);
} else {
cwdRawWrap.hidden = true;
}
@@ -288,8 +269,12 @@ export function openSpawnModal(
row.type = "button";
row.className = "recent-row";
row.dataset.cwd = dir.cwd;
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
// and `dataset.cwd` (what's actually submitted).
const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir));
row.innerHTML = `
<span class="recent-dir">${escapeHtml(dir.cwd)}</span>
<span class="recent-dir" title="${escapeHtml(dir.cwd)}">${display}</span>
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
row.addEventListener("click", () => selectRecent(dir));
return row;
@@ -300,10 +285,7 @@ export function openSpawnModal(
row.type = "button";
row.className = "recent-row recent-other";
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
row.addEventListener("click", () => {
const home = daemons.get(selectedHostId)?.home_dir ?? "";
showRawCwd(cwd || home);
});
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
return row;
}
@@ -329,7 +311,7 @@ export function openSpawnModal(
wtBody.hidden = true;
let root: string | undefined;
try {
root = (await gitRoot(selectedHostId, cwd)).root;
root = (await gitRoot(cwd)).root;
} catch {
root = undefined;
}
@@ -358,86 +340,7 @@ export function openSpawnModal(
}
});
// ---- host tiles ------------------------------------------------------
// Order hosts by most-recently-spawned-on first, so the common case is the
// pre-selected tile.
function orderedHosts(): DaemonInfo[] {
const list = [...daemons.values()];
const lastUsed = new Map<string, number>();
for (const s of sessions?.values() ?? []) {
const t = Date.parse(s.created_at);
if (Number.isFinite(t)) {
lastUsed.set(s.daemon_id, Math.max(lastUsed.get(s.daemon_id) ?? 0, t));
}
}
return list.sort((a, b) => (lastUsed.get(b.daemon_id) ?? 0) - (lastUsed.get(a.daemon_id) ?? 0));
}
function selectHost(id: string): void {
selectedHostId = id;
for (const tile of hostTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
}
// Recents + any worktree inference are host-scoped — refresh on change.
cwd = "";
inferredBaseRepo = null;
if (wtEnabled.checked) {
wtEnabled.checked = false;
wtBody.hidden = true;
wtHint.hidden = true;
}
void loadRecents();
}
function renderHostTiles(): void {
hostTiles.replaceChildren();
const ordered = orderedHosts();
if (ordered.length === 0) {
const empty = document.createElement("p");
empty.className = "picker-loading";
empty.textContent = "(no daemons connected)";
hostTiles.appendChild(empty);
return;
}
const visible = showAllHosts ? ordered : ordered.slice(0, MAX_HOST_TILES);
for (const d of visible) {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile tile-host";
tile.dataset.id = d.daemon_id;
tile.innerHTML = `
<span class="tile-label">${escapeHtml(d.hostname)}</span>
<span class="tile-sub">${escapeHtml(d.daemon_id)}</span>`;
tile.addEventListener("click", () => selectHost(d.daemon_id));
hostTiles.appendChild(tile);
}
if (!showAllHosts && ordered.length > MAX_HOST_TILES) {
const more = document.createElement("button");
more.type = "button";
more.className = "tile tile-more";
more.innerHTML = `<span class="tile-label">More…</span>`;
more.addEventListener("click", () => {
showAllHosts = true;
renderHostTiles();
// Keep the current selection highlighted after re-render.
if (selectedHostId) {
hostTiles
.querySelector(`.tile[data-id="${cssEscape(selectedHostId)}"]`)
?.classList.add("is-active");
}
});
hostTiles.appendChild(more);
}
}
renderHostTiles();
// Pre-select: a clone's host if present & connected, else the most-recent.
const ordered = orderedHosts();
const initialHost =
prefill?.daemon_id && daemons.has(prefill.daemon_id)
? prefill.daemon_id
: ordered[0]?.daemon_id;
if (initialHost) selectHost(initialHost);
void loadRecents();
// ---- clone seed ------------------------------------------------------
if (prefill) {
@@ -447,9 +350,7 @@ export function openSpawnModal(
}
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
const autocompletes: DirAutocompleteHandle[] = [
attachDirAutocomplete(cwdRaw, () => selectedHostId),
];
const autocompletes: DirAutocompleteHandle[] = [attachDirAutocomplete(cwdRaw)];
// ---- lifecycle -------------------------------------------------------
const close = () => {
@@ -473,10 +374,6 @@ export function openSpawnModal(
form.addEventListener("submit", async (e) => {
e.preventDefault();
errEl.textContent = "";
if (!selectedHostId) {
errEl.textContent = "Pick a host.";
return;
}
if (!cwd) {
errEl.textContent = "Pick a working directory.";
return;
@@ -491,7 +388,6 @@ export function openSpawnModal(
if (name) metadata.name = name;
const req: CreateSessionRequest = {
daemon_id: selectedHostId,
cwd,
command,
metadata,
@@ -537,10 +433,3 @@ function escapeHtml(s: string): string {
.replace(/"/g, "&quot;");
}
/** Escape a string for use inside a CSS attribute selector. Uses the native
* `CSS.escape` when available, with a conservative fallback. */
function cssEscape(s: string): string {
const cssApi = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS;
if (cssApi?.escape) return cssApi.escape(s);
return s.replace(/["\\]/g, "\\$&");
}

Some files were not shown because too many files have changed in this diff Show More