Compare commits
18
Commits
@@ -1,10 +0,0 @@
|
||||
target/
|
||||
worktrees/
|
||||
.git/
|
||||
.github/
|
||||
.claude/
|
||||
docs/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
*.md
|
||||
.env*
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(®istry, &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(®istry, 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(®istry, &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(®istry, 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)))
|
||||
}
|
||||
|
||||
@@ -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,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)]
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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(¶ms.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(¶ms.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(());
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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, ®istry.http_base(), TOKEN).await;
|
||||
daemons.push(d);
|
||||
}
|
||||
let client = TestClient::new(®istry.http_base(), ®istry.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",
|
||||
®istry.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",
|
||||
®istry.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(®istry.http_base(), ®istry.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",
|
||||
®istry.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",
|
||||
®istry.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(®istry.http_base(), ®istry.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", ®istry_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:?}"),
|
||||
}
|
||||
|
||||
@@ -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", ®istry.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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+31
-1214
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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;"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(®istry, &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(®istry, "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(®istry, "laptop", "tok", "{ not valid json").await,
|
||||
"malformed frame must be skipped, not drop the WS"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, "&")
|
||||
@@ -211,42 +171,19 @@ function escapeHtml(s: string): string {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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, """);
|
||||
}
|
||||
|
||||
/** 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
Reference in New Issue
Block a user