Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca585447ff | ||
|
|
71301861c0 | ||
|
|
2ce1dc43fe | ||
|
|
e99e21260b | ||
|
|
cea06f9204 | ||
|
|
1199596528 | ||
|
|
6d21061799 | ||
|
|
431f9def6b | ||
|
|
d8acc3adde | ||
|
|
95f0e42df5 | ||
|
|
6f8de8de7c | ||
|
|
018f44727c | ||
|
|
85e7749e5a | ||
|
|
35bc5bfb40 | ||
|
|
059ae86fb8 | ||
|
|
e3996dec76 | ||
|
|
503f01ae26 | ||
|
|
790d1e2075 | ||
|
|
cbfd5eee12 | ||
|
|
10cb47c50b | ||
|
|
dfc4ee91e1 | ||
|
|
d5485b8f5d | ||
|
|
53660407e3 | ||
|
|
a51809f25a | ||
|
|
135152e930 | ||
|
|
9d777e2a22 | ||
|
|
cd7e76d911 | ||
|
|
5c3997d1d3 | ||
|
|
dbbe330159 | ||
|
|
1d611ee402 | ||
|
|
f31377c34f | ||
|
|
812861f883 | ||
|
|
745e9fde8b | ||
|
|
30bd9d9042 | ||
|
|
6596104789 | ||
|
|
b20651c33e | ||
|
|
031c80a40b | ||
|
|
db44650a17 | ||
|
|
f01771568b | ||
|
|
63898cef25 | ||
|
|
fc53d74850 | ||
|
|
ef3d314296 | ||
|
|
148f97fa71 | ||
|
|
39cfa50d68 | ||
|
|
02356f84ab | ||
|
|
01a1073943 | ||
|
|
c5d7a6bf80 | ||
|
|
401afbc812 | ||
|
|
66498415f1 | ||
|
|
69586a4da5 | ||
|
|
8ff5046ff3 | ||
|
|
bf7def5cf1 | ||
|
|
c856333e4a | ||
|
|
de097cf17f | ||
|
|
1bb464be4b | ||
|
|
6790f41652 | ||
|
|
d006f4a7bd | ||
|
|
d6d433770b | ||
|
|
7d0dc6597a | ||
|
|
58b732d400 | ||
|
|
347b6bdc2a | ||
|
|
a1b6c85f8d | ||
|
|
3b69c98cca | ||
|
|
9286eb09ba | ||
|
|
57d417f7a0 | ||
|
|
70c592e884 |
@@ -1,10 +0,0 @@
|
|||||||
target/
|
|
||||||
worktrees/
|
|
||||||
.git/
|
|
||||||
.github/
|
|
||||||
.claude/
|
|
||||||
docs/
|
|
||||||
web/node_modules/
|
|
||||||
web/dist/
|
|
||||||
*.md
|
|
||||||
.env*
|
|
||||||
@@ -52,3 +52,48 @@ jobs:
|
|||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: cargo test --all
|
run: cargo test --all
|
||||||
|
|
||||||
|
# NAP-271: the umbrella `maestro` binary is feature-gated (cli / daemon /
|
||||||
|
# tui). Verify each subset compiles and its tests pass on its own, and
|
||||||
|
# that building with no features at all is rejected.
|
||||||
|
- name: feature-gate matrix
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for f in cli daemon tui; do
|
||||||
|
echo "::group::maestro --no-default-features --features $f"
|
||||||
|
cargo build -p maestro --no-default-features --features "$f"
|
||||||
|
cargo test -p maestro --no-default-features --features "$f"
|
||||||
|
echo "::endgroup::"
|
||||||
|
done
|
||||||
|
echo "::group::maestro with no features must fail to compile"
|
||||||
|
if cargo build -p maestro --no-default-features 2>/dev/null; then
|
||||||
|
echo "expected build with no features to fail, but it succeeded" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
web:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Node.js + pnpm
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends curl ca-certificates
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: pnpm -C web install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: typecheck
|
||||||
|
run: pnpm -C web typecheck
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: pnpm -C web test
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: pnpm -C web build
|
||||||
|
|||||||
+10
@@ -5,3 +5,13 @@ Cargo.lock.bak
|
|||||||
|
|
||||||
# Local git worktrees for in-flight branches (never tracked)
|
# Local git worktrees for in-flight branches (never tracked)
|
||||||
worktrees/
|
worktrees/
|
||||||
|
|
||||||
|
# nix build output symlinks
|
||||||
|
/result
|
||||||
|
/result-*
|
||||||
|
|
||||||
|
# direnv (`use flake`)
|
||||||
|
/.direnv/
|
||||||
|
|
||||||
|
# `nix run .#dev` scratch state (tmux socket)
|
||||||
|
/.dev/
|
||||||
|
|||||||
@@ -1,54 +1,78 @@
|
|||||||
# Maestro
|
# 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
|
## 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
|
## Repo layout
|
||||||
|
|
||||||
Cargo workspace. NAP-245 unified the per-binary crates behind a single
|
Cargo workspace. NAP-245 unified the per-binary crates behind a single
|
||||||
`crates/maestro/` umbrella that ships the `maestro` binary with feature gates
|
`crates/maestro/` umbrella that ships the `maestro` binary with feature gates
|
||||||
(`cli` / `daemon` / `registry`, default = all three). The library crates kept
|
(`cli` / `daemon` / `tui`, default = all three).
|
||||||
their existing shape; only the per-binary `main.rs` files were retired.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/
|
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-protocol/ # shared wire types (NAP-211)
|
||||||
├── maestro-config/ # shared on-disk config schema (NAP-233)
|
├── maestro-config/ # shared on-disk config schema (NAP-233)
|
||||||
├── maestro-names/ # orchestra-themed session-handle generator (#23)
|
├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216)
|
||||||
├── maestro-cli/ # `maestro list/spawn/attach/kill/policy` dispatch (NAP-216)
|
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API (NAP-212, NAP-214)
|
||||||
├── 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-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249)
|
├── maestro-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249)
|
||||||
├── maestro-tui/ # `maestro tui` — ratatui session manager (NAP-275 epic)
|
├── maestro-tui/ # `maestro tui` — ratatui session manager (NAP-275 epic)
|
||||||
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
|
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
|
||||||
└── maestro-e2e/ # cross-process integration tests (NAP-217)
|
└── maestro-e2e/ # cross-process integration tests (NAP-217)
|
||||||
web/ # browser SPA (NAP-218)
|
web/ # browser SPA (NAP-218)
|
||||||
|
flake.nix # Nix flake: packages, checks, dev shell, `nix run .#dev`
|
||||||
|
nix/ # Home Manager module (`services.maestro`)
|
||||||
```
|
```
|
||||||
|
|
||||||
See `docs/INSTALL.md` for end-user install instructions.
|
See `docs/INSTALL.md` for end-user install instructions.
|
||||||
|
|
||||||
|
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||||
|
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||||
|
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||||
|
the UI is exposed on the network. The dev port is deliberately not the daemon's
|
||||||
|
own default 7070 (`devDaemonPort` in `flake.nix`), so the stack never fights a
|
||||||
|
real daemon on the same host.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **TDD by default.** Failing test first, then minimal impl. Commit test + impl together.
|
- **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.
|
- **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`.
|
- **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`.
|
- **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
+10
-155
@@ -317,35 +317,6 @@ dependencies = [
|
|||||||
"static_assertions",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -437,15 +408,6 @@ version = "2.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "deranged"
|
|
||||||
version = "0.5.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
|
||||||
dependencies = [
|
|
||||||
"powerfmt",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -488,15 +450,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "document-features"
|
|
||||||
version = "0.2.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
|
||||||
dependencies = [
|
|
||||||
"litrs",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
@@ -1015,19 +968,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1073,12 +1013,6 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "litrs"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1116,7 +1050,6 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"maestro-cli",
|
"maestro-cli",
|
||||||
"maestro-daemon",
|
"maestro-daemon",
|
||||||
"maestro-registry",
|
|
||||||
"maestro-tui",
|
"maestro-tui",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -1133,9 +1066,8 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"maestro-config",
|
"maestro-config",
|
||||||
"maestro-protocol",
|
"maestro-protocol",
|
||||||
|
"maestro-testing",
|
||||||
"nix",
|
"nix",
|
||||||
"reqwest",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -1144,7 +1076,6 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"urlencoding",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1153,6 +1084,7 @@ name = "maestro-config"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"maestro-protocol",
|
||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -1168,11 +1100,9 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"dirs",
|
"dirs",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"jsonwebtoken",
|
|
||||||
"libc",
|
"libc",
|
||||||
"maestro-config",
|
"maestro-config",
|
||||||
"maestro-protocol",
|
"maestro-protocol",
|
||||||
"maestro-registry",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1180,7 +1110,6 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.21.0",
|
"tokio-tungstenite 0.21.0",
|
||||||
"toml",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
@@ -1194,8 +1123,8 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"maestro-protocol",
|
"maestro-protocol",
|
||||||
|
"maestro-testing",
|
||||||
"maestro-tui",
|
"maestro-tui",
|
||||||
"reqwest",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -1217,45 +1146,29 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "maestro-names"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"rand 0.8.6",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maestro-protocol"
|
name = "maestro-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maestro-registry"
|
name = "maestro-testing"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"jsonwebtoken",
|
|
||||||
"maestro-config",
|
|
||||||
"maestro-protocol",
|
"maestro-protocol",
|
||||||
"rand 0.8.6",
|
|
||||||
"reqwest",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.21.0",
|
|
||||||
"toml",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1270,8 +1183,8 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"maestro-config",
|
"maestro-config",
|
||||||
"maestro-protocol",
|
"maestro-protocol",
|
||||||
|
"maestro-testing",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"reqwest",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1292,6 +1205,8 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"maestro-config",
|
"maestro-config",
|
||||||
|
"maestro-protocol",
|
||||||
|
"maestro-testing",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1301,6 +1216,7 @@ dependencies = [
|
|||||||
"tokio-tungstenite 0.21.0",
|
"tokio-tungstenite 0.21.0",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1373,12 +1289,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -1456,12 +1366,6 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "powerfmt"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -1490,22 +1394,6 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -1707,8 +1595,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
|
||||||
"cookie_store",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -2165,37 +2051,6 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
+2
-4
@@ -16,16 +16,14 @@ clap = { version = "4.5", features = ["derive", "env"] }
|
|||||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||||
jsonwebtoken = { version = "9", default-features = false }
|
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
maestro-config = { path = "crates/maestro-config" }
|
maestro-config = { path = "crates/maestro-config" }
|
||||||
maestro-names = { path = "crates/maestro-names" }
|
|
||||||
maestro-protocol = { path = "crates/maestro-protocol" }
|
maestro-protocol = { path = "crates/maestro-protocol" }
|
||||||
|
maestro-testing = { path = "crates/maestro-testing" }
|
||||||
maestro-tui = { path = "crates/maestro-tui" }
|
maestro-tui = { path = "crates/maestro-tui" }
|
||||||
nix = { version = "0.29", default-features = false, features = ["term", "signal"] }
|
nix = { version = "0.29", default-features = false, features = ["term", "signal"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||||
rust-embed = "8"
|
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Tints/glow derive from the accent at low alpha: `--accent-tint` (0.12),
|
|||||||
## Typography
|
## Typography
|
||||||
|
|
||||||
**Monospace is the UI typeface** (brand identity), system-stack only (offline-
|
**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-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).
|
- `--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
|
## Layout & structure
|
||||||
|
|
||||||
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
||||||
breakpoint stacks the header (full-width search, wrapped actions) and drops the
|
breakpoint stacks the header (full-width search, wrapped actions). `≥600px`
|
||||||
daemon chips to their own wrapped line. `≥600px` switches the spawn modal from
|
switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
|
||||||
bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
|
|
||||||
terminal atmosphere (very low contrast, not decoration).
|
terminal atmosphere (very low contrast, not decoration).
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
@@ -96,10 +95,10 @@ terminal atmosphere (very low contrast, not decoration).
|
|||||||
Three surfaces, all DOM built in TS, styled by class:
|
Three surfaces, all DOM built in TS, styled by class:
|
||||||
|
|
||||||
- **Catalog** — sticky blurred header (pulsing wordmark, search, theme toggle,
|
- **Catalog** — sticky blurred header (pulsing wordmark, search, theme toggle,
|
||||||
Spawn); status bar (`N sessions · M live` tally + daemon chips); session tree
|
Spawn); status bar (`N sessions · M live` tally); session tree (collapsible
|
||||||
(collapsible folders with counts, leaf rows with status dot, name, `@daemon`
|
directory folders with counts, leaf rows with status dot, name, command meta,
|
||||||
+ command meta, exited badge, reveal-on-hover kill). Empty states are
|
exited badge, reveal-on-hover kill). Empty states are distinct and teach:
|
||||||
distinct and teach: waiting-for-daemon, no-sessions, no-search-match.
|
no-sessions, no-search-match.
|
||||||
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
||||||
a framed dark xterm with a green cursor.
|
a framed dark xterm with a green cursor.
|
||||||
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
|
- **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)
|
## Dev review aids (DEV-only, stripped from production)
|
||||||
|
|
||||||
`web/src/mock.ts` + query params seed a representative catalog without a running
|
`web/src/mock.ts` + query params seed a representative catalog without a running
|
||||||
registry: `?mock` (seed sessions/daemons), `?mock&spawn` (open the spawn
|
daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal),
|
||||||
modal), `?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
|
`?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"]
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Common dev / build entry points. Run `just` with no args to list.
|
# Common dev / build entry points. Run `just` with no args to list.
|
||||||
|
|
||||||
|
# Run the full local stack — daemon on 127.0.0.1:7071 plus Vite on :5173,
|
||||||
|
# ordered and health-gated by process-compose. Ctrl-C stops everything.
|
||||||
|
dev:
|
||||||
|
nix run .#dev
|
||||||
|
|
||||||
# Build the maestro-web binary together with the SPA bundle it embeds.
|
# Build the maestro-web binary together with the SPA bundle it embeds.
|
||||||
web:
|
web:
|
||||||
pnpm -C web install --frozen-lockfile
|
pnpm -C web install --frozen-lockfile
|
||||||
@@ -8,7 +13,7 @@ web:
|
|||||||
|
|
||||||
# Run the web bundle in dev mode against a local daemon — Vite serves the
|
# 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
|
# 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:
|
web-dev:
|
||||||
pnpm -C web install
|
pnpm -C web install
|
||||||
pnpm -C web dev
|
pnpm -C web dev
|
||||||
|
|||||||
+12
-13
@@ -7,22 +7,21 @@ product
|
|||||||
## Users
|
## Users
|
||||||
|
|
||||||
A single technical operator (a developer) managing every interactive shell and
|
A single technical operator (a developer) managing every interactive shell and
|
||||||
agent — Claude Code, opencode, plain bash — running across multiple machines.
|
agent — Claude Code, opencode, plain bash — running on a machine. The
|
||||||
The job-to-be-done: from any device (laptop, desktop, **phone**), get an
|
job-to-be-done: from any device (laptop, desktop, **phone**), get an accurate
|
||||||
accurate at-a-glance picture of all live sessions across all hosts, then attach
|
at-a-glance picture of all live sessions on that host, then attach to one and
|
||||||
to one and drive its terminal. Usage is often glance-and-go: checking on a
|
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.
|
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,
|
Mobile-first is a hard requirement — touch targets, viewport-fit safe areas,
|
||||||
and bottom-sheet modals already reflect this.
|
and bottom-sheet modals already reflect this.
|
||||||
|
|
||||||
## Product Purpose
|
## Product Purpose
|
||||||
|
|
||||||
Maestro is a unified terminal-session orchestrator: one place to see and attach
|
Maestro is a terminal-session orchestrator: one place to see and attach to
|
||||||
to every interactive session running across every machine the operator cares
|
every interactive session running on a machine. tmux for persistence,
|
||||||
about. tmux for persistence, WebSocket for transport, a daemon-per-host
|
WebSocket for transport, one daemon per host. The web UI is the browser face of
|
||||||
federated by a small always-on registry. The web UI is the browser face of
|
that system — it renders the session catalog and provides the attach terminal
|
||||||
that system — it renders the cross-host session catalog and provides the attach
|
(xterm.js). Success = the operator trusts the picture is accurate and
|
||||||
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
|
current, and can get from "open the page" to "typing in the right session" in
|
||||||
seconds, on any device.
|
seconds, on any device.
|
||||||
|
|
||||||
@@ -51,14 +50,14 @@ efficient, calm / uncluttered, with earned moments of delight.
|
|||||||
## Design Principles
|
## Design Principles
|
||||||
|
|
||||||
1. **The scan is the product.** Optimize for instant comprehension of "what's
|
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
|
running, where, and is it healthy" across many sessions. Status legibility
|
||||||
legibility beats everything else.
|
beats everything else.
|
||||||
2. **Calm under load.** Stay uncluttered whether there are 3 sessions or 300;
|
2. **Calm under load.** Stay uncluttered whether there are 3 sessions or 300;
|
||||||
hierarchy and grouping carry the weight, not color-shouting every row.
|
hierarchy and grouping carry the weight, not color-shouting every row.
|
||||||
3. **Fast and keyboard-literate.** Low-friction, efficient, gets out of the
|
3. **Fast and keyboard-literate.** Low-friction, efficient, gets out of the
|
||||||
way; honor keyboard navigation, don't make the operator hunt.
|
way; honor keyboard navigation, don't make the operator hunt.
|
||||||
4. **Monospace as identity.** Treat the mono/sans pairing as deliberate brand
|
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.
|
precise technical data.
|
||||||
5. **Earned delight.** Personality lives in motion and micro-detail (live-status
|
5. **Earned delight.** Personality lives in motion and micro-detail (live-status
|
||||||
pulse, attach transitions, cursor cues), never in noise — always craft.
|
pulse, attach transitions, cursor cues), never in noise — always craft.
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ dirs.workspace = true
|
|||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
maestro-protocol.workspace = true
|
maestro-protocol = { workspace = true, features = ["client"] }
|
||||||
nix.workspace = true
|
nix.workspace = true
|
||||||
reqwest.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { workspace = true, features = ["macros", "rt", "sync", "time", "io-util", "io-std", "signal"] }
|
tokio = { workspace = true, features = ["macros", "rt", "sync", "time", "io-util", "io-std", "signal"] }
|
||||||
@@ -28,8 +26,10 @@ tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
|||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
urlencoding = "2"
|
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
maestro-testing.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
|
uuid.workspace = true
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ use std::os::fd::AsFd;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{ClientControl, ConnectInfo};
|
use maestro_protocol::ClientControl;
|
||||||
use nix::sys::termios::{cfmakeraw, tcgetattr, tcsetattr, SetArg, Termios};
|
use nix::sys::termios::{cfmakeraw, tcgetattr, tcsetattr, SetArg, Termios};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
@@ -24,8 +23,8 @@ pub enum AttachError {
|
|||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
#[error("termios: {0}")]
|
#[error("termios: {0}")]
|
||||||
Termios(#[from] nix::Error),
|
Termios(#[from] nix::Error),
|
||||||
#[error("invalid daemon_url: {0}")]
|
#[error(transparent)]
|
||||||
InvalidDaemonUrl(String),
|
Url(#[from] maestro_protocol::routes::UrlError),
|
||||||
#[error("invalid detach key: {0}")]
|
#[error("invalid detach key: {0}")]
|
||||||
InvalidDetachKey(String),
|
InvalidDetachKey(String),
|
||||||
#[error("stdin must be a TTY for attach")]
|
#[error("stdin must be a TTY for attach")]
|
||||||
@@ -34,11 +33,8 @@ pub enum AttachError {
|
|||||||
|
|
||||||
impl AttachError {
|
impl AttachError {
|
||||||
/// True when the failure was an inability to reach the WS endpoint at the
|
/// 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
|
/// transport level (connection refused), as opposed to an HTTP-level
|
||||||
/// daemon broker to the legacy registry direct-attach path (NAP-244).
|
/// rejection from a daemon that did answer.
|
||||||
/// 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.
|
|
||||||
pub fn is_unreachable(&self) -> bool {
|
pub fn is_unreachable(&self) -> bool {
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||||
@@ -66,47 +62,6 @@ 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.
|
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
#[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('/');
|
|
||||||
let ws_base = if let Some(rest) = base.strip_prefix("https://") {
|
|
||||||
format!("wss://{rest}")
|
|
||||||
} else if let Some(rest) = base.strip_prefix("http://") {
|
|
||||||
format!("ws://{rest}")
|
|
||||||
} else if base.starts_with("ws://") || base.starts_with("wss://") {
|
|
||||||
base.to_string()
|
|
||||||
} else {
|
|
||||||
return Err(AttachError::InvalidDaemonUrl(
|
|
||||||
home_daemon_http_base.to_string(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
Ok(format!("{ws_base}/api/attach/{session_id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RAII guard that puts the terminal into raw mode and restores on drop.
|
/// RAII guard that puts the terminal into raw mode and restores on drop.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct RawTty {
|
struct RawTty {
|
||||||
@@ -151,24 +106,9 @@ fn current_winsize() -> Option<(u16, u16)> {
|
|||||||
Some((ws.ws_col, ws.ws_row))
|
Some((ws.ws_col, ws.ws_row))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive the attach loop until the client detaches or the remote closes.
|
/// 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
|
||||||
/// `connect_info` is whatever `GET /api/sessions/:id/connect-info` returned;
|
/// byte that exits cleanly without killing the session.
|
||||||
/// `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.
|
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||||
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
||||||
@@ -273,8 +213,6 @@ pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachErro
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use maestro_protocol::ConnectInfo;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_detach_key_handles_caret_form() {
|
fn parse_detach_key_handles_caret_form() {
|
||||||
@@ -295,66 +233,4 @@ mod tests {
|
|||||||
Err(AttachError::InvalidDetachKey(_))
|
Err(AttachError::InvalidDetachKey(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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() {
|
|
||||||
let id = Uuid::from_u128(0xabcd);
|
|
||||||
assert_eq!(
|
|
||||||
broker_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(),
|
|
||||||
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(),
|
|
||||||
format!("ws://10.0.0.1:7070/api/attach/{id}")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn broker_attach_url_rejects_unknown_scheme() {
|
|
||||||
assert!(matches!(
|
|
||||||
broker_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(
|
#[command(
|
||||||
name = "maestro",
|
name = "maestro",
|
||||||
version,
|
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
|
arg_required_else_help = true
|
||||||
)]
|
)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
/// Override the registry URL for this invocation (e.g. `https://maestro.example.com`).
|
/// Override the daemon HTTP base for this invocation (e.g.
|
||||||
/// If omitted, `[shared].registry_url` from the unified config file is used.
|
/// `http://127.0.0.1:7070`). If omitted, `[cli].daemon_url` from the
|
||||||
#[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")]
|
/// unified config file is used.
|
||||||
pub registry_url: Option<String>,
|
#[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`.
|
/// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`.
|
||||||
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
|
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
|
||||||
@@ -32,23 +33,18 @@ pub struct Cli {
|
|||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// List all sessions, grouped by daemon.
|
/// List all sessions.
|
||||||
List(ListArgs),
|
List(ListArgs),
|
||||||
/// Spawn a new session on a given daemon.
|
/// Spawn a new session.
|
||||||
Spawn(SpawnArgs),
|
Spawn(SpawnArgs),
|
||||||
/// Attach to a session over WebSocket. Bytes flow through the local TTY.
|
/// Attach to a session over WebSocket. Bytes flow through the local TTY.
|
||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
/// Set the default registry URL persisted in the CLI config.
|
|
||||||
SetRegistry(SetRegistryArgs),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, clap::Args)]
|
#[derive(Debug, clap::Args)]
|
||||||
pub struct ListArgs {
|
pub struct ListArgs {
|
||||||
/// Show only sessions on this daemon.
|
|
||||||
#[arg(long)]
|
|
||||||
pub daemon: Option<String>,
|
|
||||||
/// Output as JSON instead of a table.
|
/// Output as JSON instead of a table.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub json: bool,
|
pub json: bool,
|
||||||
@@ -56,9 +52,6 @@ pub struct ListArgs {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, clap::Args)]
|
#[derive(Debug, Clone, clap::Args)]
|
||||||
pub struct SpawnArgs {
|
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).
|
/// Working directory for the session (must exist on the daemon's host).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub cwd: PathBuf,
|
pub cwd: PathBuf,
|
||||||
@@ -74,8 +67,10 @@ pub struct SpawnArgs {
|
|||||||
/// Branch name for the worktree (required with --worktree-from).
|
/// Branch name for the worktree (required with --worktree-from).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub branch: Option<String>,
|
pub branch: Option<String>,
|
||||||
/// Remove the worktree when the session is killed.
|
/// Remove the worktree when the session is killed. Kept for wire
|
||||||
#[arg(long)]
|
/// compatibility (#56): the daemon now always cleans up on kill, guarded
|
||||||
|
/// against unsaved work.
|
||||||
|
#[arg(long, default_value_t = true)]
|
||||||
pub cleanup_on_kill: bool,
|
pub cleanup_on_kill: bool,
|
||||||
/// After spawning, attach immediately.
|
/// After spawning, attach immediately.
|
||||||
#[arg(long, short = 'a')]
|
#[arg(long, short = 'a')]
|
||||||
@@ -97,12 +92,6 @@ pub struct KillArgs {
|
|||||||
pub session_id: Uuid,
|
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> {
|
fn parse_kv(s: &str) -> Result<(String, String), String> {
|
||||||
match s.split_once('=') {
|
match s.split_once('=') {
|
||||||
Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())),
|
Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())),
|
||||||
@@ -117,10 +106,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_list() {
|
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 {
|
match cli.command {
|
||||||
Command::List(args) => {
|
Command::List(args) => {
|
||||||
assert_eq!(args.daemon.as_deref(), Some("laptop"));
|
|
||||||
assert!(args.json);
|
assert!(args.json);
|
||||||
}
|
}
|
||||||
_ => panic!("wrong subcommand"),
|
_ => panic!("wrong subcommand"),
|
||||||
@@ -132,8 +120,6 @@ mod tests {
|
|||||||
let cli = Cli::try_parse_from([
|
let cli = Cli::try_parse_from([
|
||||||
"maestro",
|
"maestro",
|
||||||
"spawn",
|
"spawn",
|
||||||
"--daemon",
|
|
||||||
"vm1",
|
|
||||||
"--cwd",
|
"--cwd",
|
||||||
"/tmp",
|
"/tmp",
|
||||||
"--command",
|
"--command",
|
||||||
@@ -151,7 +137,6 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Spawn(a) => {
|
Command::Spawn(a) => {
|
||||||
assert_eq!(a.daemon, "vm1");
|
|
||||||
assert_eq!(a.command, "bash");
|
assert_eq!(a.command, "bash");
|
||||||
assert_eq!(a.branch.as_deref(), Some("feat/x"));
|
assert_eq!(a.branch.as_deref(), Some("feat/x"));
|
||||||
assert!(a.cleanup_on_kill);
|
assert!(a.cleanup_on_kill);
|
||||||
@@ -164,13 +149,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_on_kill_defaults_true_for_the_wire() {
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"maestro",
|
||||||
|
"spawn",
|
||||||
|
"--cwd",
|
||||||
|
"/tmp",
|
||||||
|
"--command",
|
||||||
|
"bash",
|
||||||
|
"--worktree-from",
|
||||||
|
"/repos/foo",
|
||||||
|
"--branch",
|
||||||
|
"feat/x",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
match cli.command {
|
||||||
|
Command::Spawn(a) => assert!(a.cleanup_on_kill),
|
||||||
|
_ => panic!("wrong subcommand"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_worktree_requires_branch() {
|
fn spawn_worktree_requires_branch() {
|
||||||
let err = Cli::try_parse_from([
|
let err = Cli::try_parse_from([
|
||||||
"maestro",
|
"maestro",
|
||||||
"spawn",
|
"spawn",
|
||||||
"--daemon",
|
|
||||||
"vm1",
|
|
||||||
"--cwd",
|
"--cwd",
|
||||||
"/tmp",
|
"/tmp",
|
||||||
"--command",
|
"--command",
|
||||||
@@ -197,8 +201,6 @@ mod tests {
|
|||||||
let err = Cli::try_parse_from([
|
let err = Cli::try_parse_from([
|
||||||
"maestro",
|
"maestro",
|
||||||
"spawn",
|
"spawn",
|
||||||
"--daemon",
|
|
||||||
"x",
|
|
||||||
"--cwd",
|
"--cwd",
|
||||||
"/",
|
"/",
|
||||||
"--command",
|
"--command",
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
//! 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.
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use maestro_config::MaestroConfigFile;
|
|
||||||
|
|
||||||
pub use maestro_config::CONFIG_PATH_ENV;
|
|
||||||
|
|
||||||
/// Last-resort home-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";
|
|
||||||
|
|
||||||
/// `~/.config/maestro/config.toml` (XDG-aware).
|
|
||||||
pub fn default_config_path() -> Result<PathBuf, ConfigError> {
|
|
||||||
Ok(maestro_config::default_config_path()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `$MAESTRO_CONFIG` if set, else [`default_config_path`].
|
|
||||||
pub fn resolve_config_path() -> Result<PathBuf, ConfigError> {
|
|
||||||
Ok(maestro_config::resolve_config_path()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
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>,
|
|
||||||
/// `[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.
|
|
||||||
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.
|
|
||||||
pub fn load(path: &Path) -> Result<Self, ConfigError> {
|
|
||||||
let file = MaestroConfigFile::load_or_default(path)?;
|
|
||||||
Ok(Self {
|
|
||||||
registry_url: file.shared.registry_url,
|
|
||||||
cookie: file.cli.cookie,
|
|
||||||
home_daemon_url: file.cli.home_daemon_url,
|
|
||||||
daemon_bind_addr: file.daemon.bind_addr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the home-daemon HTTP base the broker path should target.
|
|
||||||
///
|
|
||||||
/// 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 {
|
|
||||||
override_url
|
|
||||||
.or_else(|| self.home_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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use maestro_config::SharedSection;
|
|
||||||
|
|
||||||
fn write_file(path: &Path, body: &str) {
|
|
||||||
std::fs::write(path, body).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_file_returns_empty() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let cfg = CliConfig::load(&dir.path().join("config.toml")).unwrap();
|
|
||||||
assert_eq!(cfg, CliConfig::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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() {
|
|
||||||
// override wins over everything
|
|
||||||
let cfg = CliConfig {
|
|
||||||
home_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())),
|
|
||||||
"http://override:1"
|
|
||||||
);
|
|
||||||
// [cli].home_daemon_url next
|
|
||||||
assert_eq!(
|
|
||||||
cfg.resolve_home_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");
|
|
||||||
// built-in default when nothing is configured
|
|
||||||
let cfg = CliConfig::default();
|
|
||||||
assert_eq!(cfg.resolve_home_daemon_url(None), DEFAULT_HOME_DAEMON_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_reads_home_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]
|
|
||||||
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_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,41 +1,25 @@
|
|||||||
//! Public dispatch for the `maestro` CLI subcommands.
|
//! Public dispatch for the `maestro` CLI subcommands.
|
||||||
//!
|
//!
|
||||||
//! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
|
//! Every command talks to the local daemon (`[cli].daemon_url`, default
|
||||||
//! binary can dispatch to the same logic.
|
//! `http://127.0.0.1:7070`): `list` reads its session catalog, `spawn` / `kill`
|
||||||
//!
|
//! hit its session endpoints, and `attach` opens `WS /api/attach/:id`.
|
||||||
//! ## 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.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::attach::{broker_attach_url, parse_detach_key, run_attach, run_attach_url};
|
use maestro_protocol::DaemonClient;
|
||||||
use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, SetRegistryArgs, SpawnArgs};
|
|
||||||
use crate::config::{default_config_path, CliConfig, ConfigError};
|
use crate::attach::{parse_detach_key, run_attach_url};
|
||||||
use crate::http::{HttpError, RegistryClient};
|
use crate::cli::Command;
|
||||||
use crate::sessions::{fetch_connect_info, run_kill, run_list, run_spawn};
|
use crate::sessions::{run_kill, run_list, run_spawn};
|
||||||
|
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub enum CliError {
|
pub enum CliError {
|
||||||
#[error("config: {0}")]
|
#[error("config: {0}")]
|
||||||
Config(#[from] ConfigError),
|
Config(#[from] maestro_config::ConfigError),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Http(#[from] crate::http::HttpError),
|
Client(#[from] maestro_protocol::ClientError),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Attach(#[from] crate::attach::AttachError),
|
Attach(#[from] crate::attach::AttachError),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
@@ -43,237 +27,54 @@ pub enum CliError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a parsed top-level subcommand. The umbrella binary passes the
|
/// Dispatch a parsed top-level subcommand. The umbrella binary passes the
|
||||||
/// global flags (`--config`, `--registry-url`, `--home-daemon-url`) in
|
/// global flags (`--config`, `--daemon-url`) in alongside the variant.
|
||||||
/// alongside the variant.
|
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn dispatch(
|
pub async fn dispatch(
|
||||||
command: Command,
|
command: Command,
|
||||||
config: Option<PathBuf>,
|
config: Option<PathBuf>,
|
||||||
registry_url_override: Option<String>,
|
daemon_url_override: Option<String>,
|
||||||
home_daemon_url_override: Option<String>,
|
|
||||||
) -> Result<(), CliError> {
|
) -> Result<(), CliError> {
|
||||||
let config_path = match config {
|
let config_path = match config {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => default_config_path()?,
|
None => default_config_path()?,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let daemon = client(&config_path, daemon_url_override)?;
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
Command::SetRegistry(args) => set_registry(&config_path, args),
|
|
||||||
Command::List(args) => {
|
Command::List(args) => {
|
||||||
list(
|
let mut stdout = std::io::stdout().lock();
|
||||||
&config_path,
|
run_list(&daemon, &args, &mut stdout).await?;
|
||||||
registry_url_override,
|
|
||||||
home_daemon_url_override,
|
|
||||||
args,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
Command::Spawn(args) => {
|
Command::Spawn(args) => {
|
||||||
spawn(
|
let attach_after = args.attach;
|
||||||
&config_path,
|
let session = run_spawn(&daemon, args).await?;
|
||||||
registry_url_override,
|
println!("spawned {}", session.id);
|
||||||
home_daemon_url_override,
|
if attach_after {
|
||||||
args,
|
// `spawn --attach` has no detach-key flag; use the attach
|
||||||
)
|
// default (Ctrl-q).
|
||||||
.await
|
run_attach_url(&daemon.attach_url(session.id)?, CTRL_Q).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Command::Attach(args) => {
|
Command::Attach(args) => {
|
||||||
attach(
|
let detach = parse_detach_key(&args.detach_key)?;
|
||||||
&config_path,
|
run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?;
|
||||||
registry_url_override,
|
|
||||||
home_daemon_url_override,
|
|
||||||
args,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
Command::Kill(args) => {
|
Command::Kill(args) => {
|
||||||
kill(
|
run_kill(&daemon, &args).await?;
|
||||||
&config_path,
|
println!("killed {}", args.session_id);
|
||||||
registry_url_override,
|
|
||||||
home_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>,
|
|
||||||
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 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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::result_large_err)]
|
|
||||||
async fn spawn(
|
|
||||||
config_path: &Path,
|
|
||||||
registry_url_override: Option<String>,
|
|
||||||
home_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 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()),
|
|
||||||
};
|
|
||||||
println!("spawned {}", session.id);
|
|
||||||
|
|
||||||
if attach_after {
|
|
||||||
attach_session(
|
|
||||||
&home_url,
|
|
||||||
registry_url_override,
|
|
||||||
&cfg,
|
|
||||||
config_path,
|
|
||||||
session.id,
|
|
||||||
detach_key,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
|
||||||
|
const CTRL_Q: u8 = 0x11;
|
||||||
|
|
||||||
|
/// HTTP client pointed at the daemon's API. That surface needs no auth —
|
||||||
|
/// reaching `bind_addr` is the authorization.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
async fn attach(
|
fn client(config_path: &Path, override_url: Option<String>) -> Result<DaemonClient, CliError> {
|
||||||
config_path: &Path,
|
let cfg = MaestroConfigFile::load(config_path)?;
|
||||||
registry_url_override: Option<String>,
|
Ok(DaemonClient::new(cfg.resolve_daemon_url(override_url)))
|
||||||
home_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 detach = parse_detach_key(&args.detach_key)?;
|
|
||||||
attach_session(
|
|
||||||
&home_url,
|
|
||||||
registry_url_override,
|
|
||||||
&cfg,
|
|
||||||
config_path,
|
|
||||||
args.session_id,
|
|
||||||
detach,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::result_large_err)]
|
|
||||||
async fn kill(
|
|
||||||
config_path: &Path,
|
|
||||||
registry_url_override: Option<String>,
|
|
||||||
home_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()),
|
|
||||||
}
|
|
||||||
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.
|
|
||||||
#[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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
//! Thin HTTP client wrapper around `reqwest` that persists the
|
|
||||||
//! `maestro_session` cookie issued by the registry's cookie middleware.
|
|
||||||
|
|
||||||
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};
|
|
||||||
|
|
||||||
/// Time budget for establishing a TCP+TLS connection to the registry/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.
|
|
||||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
/// Build a `reqwest::Client` with the CLI's standard timeouts and user-agent.
|
|
||||||
///
|
|
||||||
/// 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).
|
|
||||||
pub fn build_client() -> reqwest::Client {
|
|
||||||
build_client_with_timeouts(CONNECT_TIMEOUT, REQUEST_TIMEOUT)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_client_with_timeouts(connect: Duration, request: Duration) -> reqwest::Client {
|
|
||||||
reqwest::Client::builder()
|
|
||||||
.user_agent(concat!("maestro-cli/", env!("CARGO_PKG_VERSION")))
|
|
||||||
.connect_timeout(connect)
|
|
||||||
.timeout(request)
|
|
||||||
.build()
|
|
||||||
.expect("build reqwest client")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum HttpError {
|
|
||||||
#[error("http: {0}")]
|
|
||||||
Reqwest(#[from] reqwest::Error),
|
|
||||||
#[error("server returned {status}: {body}")]
|
|
||||||
ServerError { status: StatusCode, body: String },
|
|
||||||
#[error("decode: {0}")]
|
|
||||||
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).
|
|
||||||
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`).
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct RegistryClient {
|
|
||||||
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();
|
|
||||||
Self {
|
|
||||||
base: base.trim_end_matches('/').to_string(),
|
|
||||||
inner,
|
|
||||||
cookie: Arc::new(Mutex::new(cookie)),
|
|
||||||
config_path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn base(&self) -> &str {
|
|
||||||
&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,
|
|
||||||
path: &str,
|
|
||||||
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);
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
|
|
||||||
let resp = self.send(Method::GET, path, None).await?;
|
|
||||||
ok_or_body(resp)
|
|
||||||
.await?
|
|
||||||
.json::<T>()
|
|
||||||
.await
|
|
||||||
.map_err(Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
|
|
||||||
&self,
|
|
||||||
path: &str,
|
|
||||||
body: &B,
|
|
||||||
) -> Result<T, HttpError> {
|
|
||||||
let v = serde_json::to_value(body)?;
|
|
||||||
let resp = self.send(Method::POST, path, Some(v)).await?;
|
|
||||||
ok_or_body(resp)
|
|
||||||
.await?
|
|
||||||
.json::<T>()
|
|
||||||
.await
|
|
||||||
.map_err(Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(&self, path: &str) -> Result<(), HttpError> {
|
|
||||||
let resp = self.send(Method::DELETE, path, None).await?;
|
|
||||||
ok_or_body(resp).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ok_or_body(resp: Response) -> Result<Response, HttpError> {
|
|
||||||
let status = resp.status();
|
|
||||||
if status.is_success() {
|
|
||||||
return Ok(resp);
|
|
||||||
}
|
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
Err(HttpError::ServerError { status, body })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// A peer that accepts the TCP connection but never sends a response must
|
|
||||||
/// not hang the client forever: the request timeout has to fire (NAP-262).
|
|
||||||
#[tokio::test]
|
|
||||||
async fn request_timeout_fires_when_peer_never_responds() {
|
|
||||||
use std::io::Read;
|
|
||||||
use std::net::TcpListener;
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
// Accept one connection and sit on it without ever writing a response.
|
|
||||||
let handle = std::thread::spawn(move || {
|
|
||||||
if let Ok((mut sock, _)) = listener.accept() {
|
|
||||||
let mut buf = [0u8; 1024];
|
|
||||||
let _ = sock.read(&mut buf);
|
|
||||||
std::thread::sleep(Duration::from_secs(2));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let client = build_client_with_timeouts(Duration::from_secs(5), Duration::from_millis(300));
|
|
||||||
let err = client
|
|
||||||
.get(format!("http://{addr}/"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect_err("request should time out, not hang");
|
|
||||||
assert!(err.is_timeout(), "expected timeout error, got: {err}");
|
|
||||||
|
|
||||||
let _ = handle.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
//! `maestro` CLI — thin client for the registry/daemon API.
|
//! `maestro` CLI — thin client for the local daemon's `/api/*` surface.
|
||||||
//!
|
|
||||||
//! 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.
|
|
||||||
|
|
||||||
#![deny(missing_debug_implementations)]
|
#![deny(missing_debug_implementations)]
|
||||||
|
|
||||||
pub mod attach;
|
pub mod attach;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
|
||||||
pub mod dispatch;
|
pub mod dispatch;
|
||||||
pub mod http;
|
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
|
||||||
pub use dispatch::{dispatch, CliError};
|
pub use dispatch::{dispatch, CliError};
|
||||||
|
|||||||
@@ -1,45 +1,37 @@
|
|||||||
//! `maestro list / spawn / kill` — registry HTTP calls + console formatting.
|
//! `maestro list / spawn / kill` — daemon HTTP calls + console formatting.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ConnectInfo, CreateSessionRequest, DaemonInfo, Session, SessionStatus,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
||||||
WorktreeSpec,
|
SessionStatus, WorktreeSpec,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
|
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
|
||||||
use crate::http::{HttpError, RegistryClient};
|
|
||||||
|
|
||||||
/// `maestro list`
|
/// `maestro list`
|
||||||
pub async fn run_list(
|
pub async fn run_list(
|
||||||
client: &RegistryClient,
|
client: &DaemonClient,
|
||||||
args: &ListArgs,
|
args: &ListArgs,
|
||||||
out: &mut impl Write,
|
out: &mut impl Write,
|
||||||
) -> Result<(), HttpError> {
|
) -> Result<(), ClientError> {
|
||||||
let mut sessions: Vec<Session> = client.get_json("/api/sessions").await?;
|
let sessions = client.list_sessions().await?;
|
||||||
let daemons: Vec<DaemonInfo> = client
|
let info = client.daemon_info().await.unwrap_or_default();
|
||||||
.get_json::<Vec<DaemonInfo>>("/api/daemons")
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
if let Some(d) = &args.daemon {
|
|
||||||
sessions.retain(|s| &s.daemon_id == d);
|
|
||||||
}
|
|
||||||
|
|
||||||
if args.json {
|
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();
|
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
print_session_table(out, &sessions, &daemons);
|
print_session_table(out, &sessions, &info);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `maestro spawn`
|
/// `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, ClientError> {
|
||||||
let mut env: HashMap<String, String> = HashMap::new();
|
let mut env: HashMap<String, String> = HashMap::new();
|
||||||
for (k, v) in args.env.iter() {
|
for (k, v) in args.env.iter() {
|
||||||
env.insert(k.clone(), v.clone());
|
env.insert(k.clone(), v.clone());
|
||||||
@@ -52,105 +44,79 @@ pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result<Sessi
|
|||||||
base_repo: repo.clone(),
|
base_repo: repo.clone(),
|
||||||
branch: branch.clone(),
|
branch: branch.clone(),
|
||||||
cleanup_on_kill: args.cleanup_on_kill,
|
cleanup_on_kill: args.cleanup_on_kill,
|
||||||
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = CreateSessionRequest {
|
let body = CreateSessionRequest {
|
||||||
daemon_id: args.daemon.clone(),
|
|
||||||
cwd: args.cwd.clone(),
|
cwd: args.cwd.clone(),
|
||||||
command: args.command.clone(),
|
command: args.command.clone(),
|
||||||
env,
|
env,
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
};
|
};
|
||||||
let session: Session = client.post_json("/api/sessions", &body).await?;
|
client.create_session(&body).await
|
||||||
Ok(session)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `maestro kill`
|
/// `maestro kill`
|
||||||
pub async fn run_kill(client: &RegistryClient, args: &KillArgs) -> Result<(), HttpError> {
|
pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), ClientError> {
|
||||||
client
|
client.kill_session(args.session_id).await
|
||||||
.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)]
|
#[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 sessions.is_empty() {
|
||||||
if daemons.is_empty() {
|
writeln!(out, "no sessions").ok();
|
||||||
writeln!(out, "no sessions, no daemons connected").ok();
|
|
||||||
} else {
|
|
||||||
writeln!(
|
|
||||||
out,
|
|
||||||
"no sessions ({} daemon{} connected)",
|
|
||||||
daemons.len(),
|
|
||||||
if daemons.len() == 1 { "" } else { "s" }
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group by daemon, stable order.
|
let mut sorted: Vec<&Session> = sessions.iter().collect();
|
||||||
let mut by_daemon: Vec<(String, Vec<&Session>)> = Vec::new();
|
sorted.sort_by_key(|s| s.created_at);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (daemon_id, group) in &by_daemon {
|
if !info.hostname.is_empty() {
|
||||||
let daemon = daemons.iter().find(|d| &d.daemon_id == daemon_id);
|
let warn = if maestro_protocol::is_compatible(&info.version) {
|
||||||
let hostname = daemon.map(|d| d.hostname.as_str()).unwrap_or("?");
|
""
|
||||||
let home = daemon.map(|d| d.home_dir.as_str()).unwrap_or("");
|
} else {
|
||||||
writeln!(out, "\n{daemon_id} ({hostname})").ok();
|
" (protocol mismatch)"
|
||||||
|
};
|
||||||
|
let version = if info.version.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" maestro {}{warn}", info.version)
|
||||||
|
};
|
||||||
|
writeln!(out, "\n{}{version}", 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!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {}",
|
" {:<36} {:<8} {:<9} {:<28} {command}",
|
||||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
|
s.id, status, activity, dir
|
||||||
)
|
)
|
||||||
.ok();
|
.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 +141,14 @@ mod tests {
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::{TimeZone, Utc};
|
use chrono::{TimeZone, Utc};
|
||||||
use maestro_protocol::{ActivityState, DaemonInfo, Session, SessionStatus};
|
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||||
use uuid::Uuid;
|
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 {
|
Session {
|
||||||
id: Uuid::from_u128(id),
|
id: Uuid::from_u128(id),
|
||||||
daemon_id: daemon.into(),
|
|
||||||
cwd: PathBuf::from(cwd),
|
cwd: PathBuf::from(cwd),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -195,37 +162,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn info(home: &str) -> DaemonInfo {
|
||||||
|
DaemonInfo {
|
||||||
|
hostname: "naps-laptop".into(),
|
||||||
|
home_dir: home.into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formats_grouped_table() {
|
fn formats_table() {
|
||||||
let sessions = vec![
|
let sessions = vec![
|
||||||
session("laptop", "/home/naps/maestro", "claude", 1),
|
session("/home/naps/maestro", "claude", 1),
|
||||||
session("vm1", "/srv/work/api", "bash", 2),
|
session("/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(),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
let mut buf = Vec::new();
|
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();
|
let s = String::from_utf8(buf).unwrap();
|
||||||
assert!(s.contains("laptop (naps-laptop)"), "{s}");
|
assert!(s.contains("naps-laptop"), "{s}");
|
||||||
assert!(s.contains("vm1 (vm1.lan)"), "{s}");
|
|
||||||
// Home dir collapses to ~ in the DIR column.
|
// Home dir collapses to ~ in the DIR column.
|
||||||
assert!(s.contains("~/maestro"), "{s}");
|
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("/srv/work/api"), "{s}");
|
||||||
assert!(s.contains("running"), "{s}");
|
assert!(s.contains("running"), "{s}");
|
||||||
assert!(s.contains("ACTIVITY"), "{s}");
|
assert!(s.contains("ACTIVITY"), "{s}");
|
||||||
@@ -235,35 +192,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
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"));
|
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();
|
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();
|
let out = String::from_utf8(buf).unwrap();
|
||||||
assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}");
|
assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shows_activity_and_foreground() {
|
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.activity = ActivityState::AwaitingInput;
|
||||||
s1.foreground = Some("claude".into()); // differs from spawn command
|
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();
|
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();
|
let out = String::from_utf8(buf).unwrap();
|
||||||
assert!(out.contains("awaiting"), "{out}");
|
assert!(out.contains("awaiting"), "{out}");
|
||||||
// Live foreground surfaces next to the spawn command.
|
// Live foreground surfaces next to the spawn command.
|
||||||
@@ -271,27 +214,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_sessions_with_no_daemons() {
|
fn empty_sessions() {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
super::print_session_table(&mut buf, &[], &[]);
|
super::print_session_table(&mut buf, &[], &info(""));
|
||||||
assert_eq!(
|
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! The CLI against a daemon. It had no such tests: `maestro list`'s formatting
|
||||||
|
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||||
|
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||||
|
|
||||||
|
use maestro_protocol::{DaemonClient, DaemonInfo};
|
||||||
|
use maestro_testing::{session, MockDaemon};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_reads_the_daemon_catalog() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
mock.add_session(session(Uuid::from_u128(1), "/home/mock/work", "claude"));
|
||||||
|
|
||||||
|
let sessions = mock.client().list_sessions().await.expect("list");
|
||||||
|
|
||||||
|
assert_eq!(sessions.len(), 1);
|
||||||
|
assert_eq!(sessions[0].command, "claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn info_carries_version_and_presets() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
mock.set_info(DaemonInfo {
|
||||||
|
hostname: "konishi".into(),
|
||||||
|
version: "9.9.9".into(),
|
||||||
|
home_dir: "/home/naps".into(),
|
||||||
|
presets: vec![maestro_protocol::SpawnPreset {
|
||||||
|
id: "claude".into(),
|
||||||
|
label: "Claude".into(),
|
||||||
|
command: "claude".into(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let info = mock.client().daemon_info().await.expect("info");
|
||||||
|
|
||||||
|
// The CLI's own DaemonInfo used to drop both of these fields.
|
||||||
|
assert_eq!(info.version, "9.9.9");
|
||||||
|
assert_eq!(info.presets.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_structured_failure_surfaces_the_daemon_message() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
mock.force_error(502, "tmux_failed", "no server running");
|
||||||
|
|
||||||
|
let err = mock
|
||||||
|
.client()
|
||||||
|
.list_sessions()
|
||||||
|
.await
|
||||||
|
.expect_err("should fail");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
err.daemon_message(),
|
||||||
|
Some("no server running"),
|
||||||
|
"the daemon's own message must survive: {err}"
|
||||||
|
);
|
||||||
|
assert!(!err.is_unreachable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unreachable_daemon_is_distinguishable_from_a_rejection() {
|
||||||
|
// Port 1 has nothing listening on it.
|
||||||
|
let client = DaemonClient::new("http://127.0.0.1:1");
|
||||||
|
|
||||||
|
let err = client.list_sessions().await.expect_err("should fail");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
err.is_unreachable(),
|
||||||
|
"connection refused must not read as an HTTP rejection: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn killing_an_unknown_session_reports_the_daemon_reason() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
|
||||||
|
let err = mock
|
||||||
|
.client()
|
||||||
|
.kill_session(Uuid::new_v4())
|
||||||
|
.await
|
||||||
|
.expect_err("should fail");
|
||||||
|
|
||||||
|
assert_eq!(err.daemon_message(), Some("no such session on this daemon"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_round_trips_through_the_daemon() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
|
||||||
|
let created = mock
|
||||||
|
.client()
|
||||||
|
.create_session(&maestro_protocol::CreateSessionRequest {
|
||||||
|
cwd: "/home/mock".into(),
|
||||||
|
command: "bash".into(),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata: Default::default(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
assert_eq!(created.command, "bash");
|
||||||
|
assert_eq!(mock.session_count(), 1);
|
||||||
|
|
||||||
|
mock.client().kill_session(created.id).await.expect("kill");
|
||||||
|
assert_eq!(mock.session_count(), 0);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
|
maestro-protocol.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
|||||||
+104
-423
@@ -4,41 +4,33 @@
|
|||||||
//! `$MAESTRO_CONFIG`) holds settings for every Maestro binary:
|
//! `$MAESTRO_CONFIG`) holds settings for every Maestro binary:
|
||||||
//!
|
//!
|
||||||
//! ```toml
|
//! ```toml
|
||||||
//! [shared]
|
|
||||||
//! registry_url = "https://maestro.example.com"
|
|
||||||
//! token = "<shared secret>"
|
|
||||||
//!
|
|
||||||
//! [cli]
|
//! [cli]
|
||||||
//! cookie = "<jwt cookie>"
|
//! daemon_url = "http://127.0.0.1:7070"
|
||||||
//!
|
//!
|
||||||
//! [daemon]
|
//! [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"
|
//! bind_addr = "127.0.0.1:8080"
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The CLI consumes `[shared].registry_url` as its HTTP base; the daemon
|
//! This crate owns the file schema, path resolution, and atomic save. Each
|
||||||
//! derives its WS endpoint with [`derive_daemon_ws_url`] (scheme swap +
|
//! binary applies its own env-var precedence and defaults on top of
|
||||||
//! `/daemon` suffix).
|
//! [`MaestroConfigFile`].
|
||||||
//!
|
|
||||||
//! 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`].
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
|
|
||||||
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
|
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
|
||||||
pub const DIR_NAME: &str = "maestro";
|
pub const DIR_NAME: &str = "maestro";
|
||||||
/// File name inside [`DIR_NAME`].
|
/// File name inside [`DIR_NAME`].
|
||||||
pub const FILE_NAME: &str = "config.toml";
|
pub const FILE_NAME: &str = "config.toml";
|
||||||
/// Env var that, when set, overrides the resolved config-file path.
|
|
||||||
pub const CONFIG_PATH_ENV: &str = "MAESTRO_CONFIG";
|
/// Last-resort daemon HTTP base, matching the daemon's own default `bind_addr`.
|
||||||
|
pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
@@ -50,16 +42,6 @@ pub enum ConfigError {
|
|||||||
TomlEncode(#[from] toml::ser::Error),
|
TomlEncode(#[from] toml::ser::Error),
|
||||||
#[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")]
|
#[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")]
|
||||||
NoConfigDir,
|
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}")]
|
#[error("invalid {env} ({input:?}): {source}")]
|
||||||
InvalidBindAddr {
|
InvalidBindAddr {
|
||||||
env: &'static str,
|
env: &'static str,
|
||||||
@@ -69,100 +51,51 @@ pub enum ConfigError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// On-disk representation of the unified config. Every section is optional
|
/// On-disk representation of the unified config. Every section is optional
|
||||||
/// so a partial file (e.g. just `[shared]` + `[registry]` for a registry
|
/// so a partial file (e.g. just `[daemon]`) is valid.
|
||||||
/// host) is valid.
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct MaestroConfigFile {
|
pub struct MaestroConfigFile {
|
||||||
#[serde(default, skip_serializing_if = "SharedSection::is_empty")]
|
#[serde(default)]
|
||||||
pub shared: SharedSection,
|
|
||||||
#[serde(default, skip_serializing_if = "CliSection::is_empty")]
|
|
||||||
pub cli: CliSection,
|
pub cli: CliSection,
|
||||||
#[serde(default, skip_serializing_if = "DaemonSection::is_empty")]
|
#[serde(default)]
|
||||||
pub daemon: DaemonSection,
|
pub daemon: DaemonSection,
|
||||||
#[serde(default, skip_serializing_if = "RegistrySection::is_empty")]
|
#[serde(default)]
|
||||||
pub registry: RegistrySection,
|
|
||||||
#[serde(default, skip_serializing_if = "WebSection::is_empty")]
|
|
||||||
pub web: WebSection,
|
pub web: WebSection,
|
||||||
#[serde(default, skip_serializing_if = "TuiSection::is_empty")]
|
#[serde(default)]
|
||||||
pub tui: TuiSection,
|
pub tui: TuiSection,
|
||||||
#[serde(default, skip_serializing_if = "SpawnSection::is_empty")]
|
#[serde(default)]
|
||||||
pub spawn: SpawnSection,
|
pub spawn: SpawnSection,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fields consumed by more than one binary (CLI + daemon, daemon + registry).
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
#[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 {
|
pub struct CliSection {
|
||||||
/// Persisted `maestro_session` cookie value (a JWT). Opaque to the CLI.
|
/// HTTP base URL of the daemon the CLI talks to, e.g.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
/// `http://127.0.0.1:7070`. When unset, the CLI derives it from
|
||||||
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
|
|
||||||
/// `[daemon].bind_addr` if a daemon is configured locally, else falls
|
/// `[daemon].bind_addr` if a daemon is configured locally, else falls
|
||||||
/// back to `http://127.0.0.1:7070`. The legacy registry path stays
|
/// back to `http://127.0.0.1:7070`.
|
||||||
/// available as a compat fallback via `[shared].registry_url`.
|
#[serde(default)]
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub home_daemon_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CliSection {
|
|
||||||
fn is_empty(&self) -> bool {
|
|
||||||
self.cookie.is_none() && self.home_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>,
|
|
||||||
#[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>,
|
pub daemon_url: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
|
pub struct DaemonSection {
|
||||||
|
/// Hostname advertised to clients, purely for display. Defaults to
|
||||||
|
/// `gethostname(2)`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub bind_addr: Option<SocketAddr>,
|
pub bind_addr: Option<SocketAddr>,
|
||||||
/// Root for everything daemon-local: sidecars, FIFOs, etc.
|
/// Root for everything daemon-local: sidecars, FIFOs, etc.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
pub state_dir: Option<PathBuf>,
|
pub state_dir: Option<PathBuf>,
|
||||||
/// Root for ephemeral worktrees the daemon creates on `CreateSession`
|
/// Override root for ephemeral worktrees the daemon creates on
|
||||||
/// with a `worktree` spec.
|
/// `CreateSession` with a `worktree` spec. Unset by default: worktrees
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
/// then live inside their base repo at `<repo>/worktrees/<branch>` (#56).
|
||||||
|
#[serde(default)]
|
||||||
pub worktree_root: Option<PathBuf>,
|
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
|
/// 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.
|
/// receives this much of the prior PTY output before the live stream.
|
||||||
/// `None` ⇒ daemon default (~64 KB); `Some(0)` disables replay.
|
/// `None` ⇒ daemon default (~64 KB); `Some(0)` disables replay.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
pub pty_replay_bytes: Option<usize>,
|
pub pty_replay_bytes: Option<usize>,
|
||||||
/// `TMUX_TMPDIR` the daemon exports for every `tmux` invocation (NAP-234).
|
/// `TMUX_TMPDIR` the daemon exports for every `tmux` invocation (NAP-234).
|
||||||
/// When unset, the daemon defaults to `$XDG_RUNTIME_DIR` so that bare
|
/// When unset, the daemon defaults to `$XDG_RUNTIME_DIR` so that bare
|
||||||
@@ -170,66 +103,27 @@ pub struct DaemonSection {
|
|||||||
/// maestro sessions. Set to `/tmp` to opt back into tmux's own default —
|
/// maestro sessions. Set to `/tmp` to opt back into tmux's own default —
|
||||||
/// useful if the daemon outlives the user's login session and you can't
|
/// useful if the daemon outlives the user's login session and you can't
|
||||||
/// enable `loginctl enable-linger`.
|
/// enable `loginctl enable-linger`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
pub tmux_tmpdir: Option<PathBuf>,
|
pub tmux_tmpdir: Option<PathBuf>,
|
||||||
/// Recency cap in days for the durable spawn history that backs the spawn
|
/// Recency cap in days for the durable spawn history that backs the spawn
|
||||||
/// form's "recent dirs" picker (#24). `None` ⇒ daemon default (30 days);
|
/// form's "recent dirs" picker (#24). `None` ⇒ daemon default (30 days);
|
||||||
/// `Some(0)` disables history entirely (no log written, no recents read).
|
/// `Some(0)` disables history entirely (no log written, no recents read).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
pub spawn_history_days: Option<u32>,
|
pub spawn_history_days: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonSection {
|
|
||||||
fn is_empty(&self) -> bool {
|
|
||||||
self.daemon_id.is_none()
|
|
||||||
&& self.hostname.is_none()
|
|
||||||
&& self.daemon_url.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).
|
/// `[web]` — `maestro-web` binary settings (NAP-241).
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
pub struct WebSection {
|
pub struct WebSection {
|
||||||
/// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a
|
/// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a LAN/VPN
|
||||||
/// perimeter-facing address (LAN/WG) to share with other devices on a
|
/// address to share with your own devices on a trusted segment; there is no
|
||||||
/// trusted segment; do NOT bind a public address without adding auth.
|
/// auth layer, so never bind a public address.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
pub bind_addr: Option<SocketAddr>,
|
pub bind_addr: Option<SocketAddr>,
|
||||||
/// HTTP base URL of the co-located daemon `maestro-web` proxies to.
|
/// HTTP base URL of the co-located daemon `maestro-web` proxies to.
|
||||||
/// Default `http://127.0.0.1:7070`.
|
/// Default `http://127.0.0.1:7070`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
pub daemon_url: Option<String>,
|
pub daemon_url: Option<String>,
|
||||||
/// When set, requests for `/` are proxied to a Vite dev server at this
|
|
||||||
/// URL instead of being served from the embedded bundle. Use it during
|
|
||||||
/// frontend development so live reload works.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub dev_proxy_target: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WebSection {
|
|
||||||
fn is_empty(&self) -> bool {
|
|
||||||
self.bind_addr.is_none() && self.daemon_url.is_none() && self.dev_proxy_target.is_none()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
|
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
|
||||||
@@ -237,53 +131,40 @@ impl WebSection {
|
|||||||
/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships
|
/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships
|
||||||
/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are
|
/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are
|
||||||
/// applied **over** the defaults, so a partial override is enough.
|
/// applied **over** the defaults, so a partial override is enough.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
pub struct TuiSection {
|
pub struct TuiSection {
|
||||||
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
|
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
pub leader: Option<String>,
|
pub leader: Option<String>,
|
||||||
/// Maximum interval between chord keys before a held prefix is dropped.
|
/// Maximum interval between chord keys before a held prefix is dropped.
|
||||||
/// Default `1000`.
|
/// Default `1000`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
pub chord_timeout_ms: Option<u64>,
|
pub chord_timeout_ms: Option<u64>,
|
||||||
/// Normal-mode bindings: canonical input → action string. Applied over
|
/// Normal-mode bindings: canonical input → action string. Applied over
|
||||||
/// the shipped defaults.
|
/// the shipped defaults.
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default)]
|
||||||
pub keymap_normal: BTreeMap<String, String>,
|
pub keymap_normal: BTreeMap<String, String>,
|
||||||
/// Insert-mode bindings, same semantics as `keymap_normal`.
|
/// Insert-mode bindings, same semantics as `keymap_normal`.
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default)]
|
||||||
pub keymap_insert: BTreeMap<String, String>,
|
pub keymap_insert: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TuiSection {
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.leader.is_none()
|
|
||||||
&& self.chord_timeout_ms.is_none()
|
|
||||||
&& self.keymap_normal.is_empty()
|
|
||||||
&& self.keymap_insert.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `[spawn]` — command presets for the spawn form (#22, part of #21).
|
/// `[spawn]` — command presets for the spawn form (#22, part of #21).
|
||||||
///
|
///
|
||||||
/// Defined in shared config so every binary sees the same list: the web reads
|
/// Defined in shared config so every binary sees the same list: the web reads
|
||||||
/// it via the daemon's catalog advertisement (#26), the TUI adopts it later.
|
/// it via the daemon's catalog advertisement (#26), the TUI adopts it later.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
pub struct SpawnSection {
|
pub struct SpawnSection {
|
||||||
/// Command presets offered as one-click tiles. When present, this list
|
/// Command presets offered as one-click tiles. When present, this list
|
||||||
/// **replaces** the [`builtin_presets`] defaults (the implicit `custom`
|
/// **replaces** the [`builtin_presets`] defaults (the implicit `custom`
|
||||||
/// escape hatch is still appended by [`SpawnSection::resolve`]); when
|
/// escape hatch is still appended by [`SpawnSection::resolve`]); when
|
||||||
/// absent, the built-ins are used. `presets = []` is "present but empty" —
|
/// absent, the built-ins are used. `presets = []` is "present but empty" —
|
||||||
/// it resolves to just the `custom` tile.
|
/// it resolves to just the `custom` tile.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default)]
|
||||||
pub presets: Option<Vec<SpawnPreset>>,
|
pub presets: Option<Vec<SpawnPreset>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpawnSection {
|
impl SpawnSection {
|
||||||
fn is_empty(&self) -> bool {
|
|
||||||
self.presets.is_none()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The presets to show, in order, with the implicit `custom` tile appended.
|
/// The presets to show, in order, with the implicit `custom` tile appended.
|
||||||
///
|
///
|
||||||
/// Resolution: configured `presets` if present, else [`builtin_presets`];
|
/// Resolution: configured `presets` if present, else [`builtin_presets`];
|
||||||
@@ -305,12 +186,10 @@ impl SpawnSection {
|
|||||||
/// tile, and the `command` to run. The implicit `custom` tile (see
|
/// tile, and the `command` to run. The implicit `custom` tile (see
|
||||||
/// [`custom_preset`]) reuses this shape with an empty `command` the client
|
/// [`custom_preset`]) reuses this shape with an empty `command` the client
|
||||||
/// fills from free-text input.
|
/// fills from free-text input.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
///
|
||||||
pub struct SpawnPreset {
|
/// Defined in `maestro-protocol` because it also rides `GET /api/info`; the
|
||||||
pub id: String,
|
/// on-disk `[spawn].presets` schema and the wire payload are one type.
|
||||||
pub label: String,
|
pub use maestro_protocol::SpawnPreset;
|
||||||
pub command: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reserved id for the implicit free-text tile. A user-defined preset may not
|
/// Reserved id for the implicit free-text tile. A user-defined preset may not
|
||||||
/// claim it (see [`SpawnConfigError::ReservedCustomId`]) since it's always
|
/// claim it (see [`SpawnConfigError::ReservedCustomId`]) since it's always
|
||||||
@@ -387,47 +266,29 @@ fn validate_presets(presets: &[SpawnPreset]) -> Result<(), SpawnConfigError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MaestroConfigFile {
|
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> {
|
pub fn load(path: &Path) -> Result<Self, ConfigError> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(ConfigError::MissingFile {
|
return Ok(Self::default());
|
||||||
path: path.to_path_buf(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
let bytes = std::fs::read(path)?;
|
let bytes = std::fs::read(path)?;
|
||||||
let s = std::str::from_utf8(&bytes).unwrap_or("");
|
let s = std::str::from_utf8(&bytes).unwrap_or("");
|
||||||
Ok(toml::from_str(s)?)
|
Ok(toml::from_str(s)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load if present, else return an empty config. Use for callers that
|
/// Daemon HTTP base a client should talk to.
|
||||||
/// want a default-on-missing read (e.g. CLI's `set-registry` bootstrap,
|
///
|
||||||
/// registry's first-run token generation).
|
/// Precedence: explicit `override_url` (flag or env), then
|
||||||
pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
|
/// `[cli].daemon_url`, then `http://<[daemon].bind_addr>`, then
|
||||||
if !path.exists() {
|
/// [`DEFAULT_DAEMON_URL`]. Always yields a value — reachability is decided
|
||||||
return Ok(Self::default());
|
/// by whether the connection succeeds.
|
||||||
}
|
pub fn resolve_daemon_url(&self, override_url: Option<String>) -> String {
|
||||||
Self::load(path)
|
override_url
|
||||||
|
.or_else(|| self.cli.daemon_url.clone())
|
||||||
|
.or_else(|| self.daemon.bind_addr.map(|a| format!("http://{a}")))
|
||||||
|
.unwrap_or_else(|| DEFAULT_DAEMON_URL.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomic write (tmp + rename). Creates parent dirs on demand.
|
|
||||||
pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let body = toml::to_string_pretty(self)?;
|
|
||||||
let tmp = path.with_extension("toml.tmp");
|
|
||||||
std::fs::write(&tmp, body)?;
|
|
||||||
std::fs::rename(&tmp, path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `$MAESTRO_CONFIG` if set, else [`default_config_path`].
|
|
||||||
pub fn resolve_config_path() -> Result<PathBuf, ConfigError> {
|
|
||||||
if let Some(p) = std::env::var_os(CONFIG_PATH_ENV) {
|
|
||||||
return Ok(PathBuf::from(p));
|
|
||||||
}
|
|
||||||
default_config_path()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `~/.config/maestro/config.toml` (XDG-aware).
|
/// `~/.config/maestro/config.toml` (XDG-aware).
|
||||||
@@ -436,34 +297,10 @@ pub fn default_config_path() -> Result<PathBuf, ConfigError> {
|
|||||||
Ok(base.join(DIR_NAME).join(FILE_NAME))
|
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
|
/// 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
|
/// failure with the originating env-var name so every binary surfaces the same
|
||||||
/// `invalid <ENV> (...)` diagnostic. Shared by the daemon, registry, and web
|
/// `invalid <ENV> (...)` diagnostic. Shared by the daemon and web `bind_addr`
|
||||||
/// `bind_addr` resolution paths (NAP-273).
|
/// resolution paths (NAP-273).
|
||||||
pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigError> {
|
pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigError> {
|
||||||
raw.parse().map_err(|source| ConfigError::InvalidBindAddr {
|
raw.parse().map_err(|source| ConfigError::InvalidBindAddr {
|
||||||
env,
|
env,
|
||||||
@@ -482,32 +319,10 @@ pub fn default_state_dir(subdir: &str) -> Result<PathBuf, ConfigError> {
|
|||||||
.ok_or(ConfigError::NoConfigDir)?;
|
.ok_or(ConfigError::NoConfigDir)?;
|
||||||
Ok(base.join(subdir))
|
Ok(base.join(subdir))
|
||||||
}
|
}
|
||||||
|
/// Capacity of the session-event broadcast channel (`CatalogEvent`).
|
||||||
/// Capacities for the daemon/registry broadcast channels (session events and
|
pub const EVENTS_CAPACITY: usize = 256;
|
||||||
/// PTY byte fan-out). These were four independent `= 256` constants scattered
|
/// Capacity of the per-session PTY byte broadcast channel.
|
||||||
/// across the daemon and registry; centralizing them here makes the coincidence
|
pub const PTY_CAPACITY: usize = 256;
|
||||||
/// 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`).
|
|
||||||
pub events_capacity: usize,
|
|
||||||
/// Per-session PTY byte broadcast channel.
|
|
||||||
pub pty_capacity: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BroadcastTuning {
|
|
||||||
/// The historical defaults — every channel was 256 by coincidence.
|
|
||||||
pub const DEFAULT: Self = Self {
|
|
||||||
events_capacity: 256,
|
|
||||||
pty_capacity: 256,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for BroadcastTuning {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::DEFAULT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -538,83 +353,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn broadcast_tuning_defaults_to_256() {
|
fn broadcast_capacities_are_the_historical_defaults() {
|
||||||
assert_eq!(BroadcastTuning::default(), BroadcastTuning::DEFAULT);
|
assert_eq!(EVENTS_CAPACITY, 256);
|
||||||
assert_eq!(BroadcastTuning::DEFAULT.events_capacity, 256);
|
assert_eq!(PTY_CAPACITY, 256);
|
||||||
assert_eq!(BroadcastTuning::DEFAULT.pty_capacity, 256);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_file_strict_load_errors_with_pointer_to_configure() {
|
fn missing_file_loads_as_empty_config() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("config.toml");
|
let path = dir.path().join("config.toml");
|
||||||
let err = MaestroConfigFile::load(&path).unwrap_err();
|
let cfg = MaestroConfigFile::load(&path).unwrap();
|
||||||
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();
|
|
||||||
assert_eq!(cfg, MaestroConfigFile::default());
|
assert_eq!(cfg, MaestroConfigFile::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn full_file_round_trips() {
|
|
||||||
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: 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()),
|
|
||||||
dev_proxy_target: Some("http://127.0.0.1:5173".into()),
|
|
||||||
},
|
|
||||||
tui: TuiSection {
|
|
||||||
leader: Some("ctrl-b".into()),
|
|
||||||
chord_timeout_ms: Some(750),
|
|
||||||
keymap_normal: BTreeMap::from([("i".into(), "ShowHelp".into())]),
|
|
||||||
keymap_insert: BTreeMap::new(),
|
|
||||||
},
|
|
||||||
spawn: SpawnSection {
|
|
||||||
presets: Some(vec![SpawnPreset {
|
|
||||||
id: "claude".into(),
|
|
||||||
label: "Claude".into(),
|
|
||||||
command: "claude --resume".into(),
|
|
||||||
}]),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
cfg.save(&path).unwrap();
|
|
||||||
assert_eq!(MaestroConfigFile::load(&path).unwrap(), cfg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spawn_presets_default_to_builtins_plus_custom() {
|
fn spawn_presets_default_to_builtins_plus_custom() {
|
||||||
// Absent [spawn].presets → built-ins, with the implicit custom tile last.
|
// Absent [spawn].presets → built-ins, with the implicit custom tile last.
|
||||||
@@ -746,128 +497,58 @@ presets = [
|
|||||||
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn save_omits_empty_spawn_section() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
MaestroConfigFile {
|
|
||||||
shared: SharedSection {
|
|
||||||
token: Some("t".into()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.save(&path)
|
|
||||||
.unwrap();
|
|
||||||
let body = std::fs::read_to_string(&path).unwrap();
|
|
||||||
assert!(!body.contains("[spawn]"), "body: {body}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_file_loads_with_section_defaults() {
|
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 dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("config.toml");
|
let path = dir.path().join("config.toml");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&path,
|
&path,
|
||||||
r#"
|
r#"
|
||||||
[shared]
|
[daemon]
|
||||||
token = "abc"
|
|
||||||
|
|
||||||
[registry]
|
|
||||||
bind_addr = "127.0.0.1:9090"
|
bind_addr = "127.0.0.1:9090"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = MaestroConfigFile::load(&path).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.cli, CliSection::default());
|
||||||
assert_eq!(cfg.daemon, DaemonSection::default());
|
assert_eq!(cfg.web, WebSection::default());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.registry.bind_addr,
|
cfg.daemon.bind_addr,
|
||||||
Some("127.0.0.1:9090".parse().unwrap())
|
Some("127.0.0.1:9090".parse().unwrap())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn file_with(body: &str) -> MaestroConfigFile {
|
||||||
fn save_omits_empty_sections() {
|
toml::from_str(body).expect("parse")
|
||||||
// Avoid littering the file with empty `[cli]` / `[daemon]` tables
|
|
||||||
// when only one component is configured.
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("config.toml");
|
|
||||||
let cfg = MaestroConfigFile {
|
|
||||||
shared: SharedSection {
|
|
||||||
token: Some("t".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("[cli]"), "body: {body}");
|
|
||||||
assert!(!body.contains("[daemon]"), "body: {body}");
|
|
||||||
assert!(!body.contains("[registry]"), "body: {body}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn save_atomic_creates_parent_dirs() {
|
fn daemon_url_prefers_an_explicit_override() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let cfg = file_with("[cli]\ndaemon_url = \"http://from-file:1\"\n");
|
||||||
let path = dir.path().join("nested/here/config.toml");
|
|
||||||
MaestroConfigFile::default().save(&path).unwrap();
|
|
||||||
assert!(path.exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_config_path_respects_env_var() {
|
|
||||||
// We can't safely tweak the process env in a unit test (it races with
|
|
||||||
// other tests). Exercise the default path branch instead and rely on
|
|
||||||
// an integration-level smoke that env-override works.
|
|
||||||
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!(
|
assert_eq!(
|
||||||
derive_daemon_ws_url("http://r.example").unwrap(),
|
cfg.resolve_daemon_url(Some("http://override:2".into())),
|
||||||
"ws://r.example/daemon"
|
"http://override:2"
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
derive_daemon_ws_url("http://r.example:8080").unwrap(),
|
|
||||||
"ws://r.example:8080/daemon"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn derive_daemon_ws_url_swaps_https_to_wss() {
|
fn daemon_url_falls_back_through_cli_then_bind_addr_then_default() {
|
||||||
assert_eq!(
|
let from_cli = file_with("[cli]\ndaemon_url = \"http://from-cli:1\"\n");
|
||||||
derive_daemon_ws_url("https://r.example").unwrap(),
|
assert_eq!(from_cli.resolve_daemon_url(None), "http://from-cli:1");
|
||||||
"wss://r.example/daemon"
|
|
||||||
);
|
let from_bind = file_with("[daemon]\nbind_addr = \"10.0.0.1:7070\"\n");
|
||||||
|
assert_eq!(from_bind.resolve_daemon_url(None), "http://10.0.0.1:7070");
|
||||||
|
|
||||||
|
let empty = MaestroConfigFile::default();
|
||||||
|
assert_eq!(empty.resolve_daemon_url(None), DEFAULT_DAEMON_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn derive_daemon_ws_url_normalises_trailing_slash() {
|
fn cli_daemon_url_beats_a_locally_configured_bind_addr() {
|
||||||
assert_eq!(
|
let cfg = file_with(
|
||||||
derive_daemon_ws_url("https://r.example/").unwrap(),
|
"[cli]\ndaemon_url = \"http://explicit:1\"\n[daemon]\nbind_addr = \"10.0.0.1:7070\"\n",
|
||||||
"wss://r.example/daemon"
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(cfg.resolve_daemon_url(None), "http://explicit:1");
|
||||||
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
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
publish.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]
|
[lib]
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
@@ -16,7 +16,6 @@ chrono.workspace = true
|
|||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
jsonwebtoken.workspace = true
|
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
maestro-protocol.workspace = true
|
maestro-protocol.workspace = true
|
||||||
@@ -24,16 +23,14 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
|
||||||
toml.workspace = true
|
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
urlencoding = "2"
|
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
tokio-tungstenite.workspace = true
|
||||||
|
urlencoding = "2"
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
maestro-registry = { path = "../maestro-registry" }
|
|
||||||
# `test-util` enables `start_paused` for instant timeout tests (NAP-270).
|
# `test-util` enables `start_paused` for instant timeout tests (NAP-270).
|
||||||
tokio = { workspace = true, features = ["test-util"] }
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
|
|||||||
@@ -1,124 +1,31 @@
|
|||||||
//! 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
|
//! - Binary frames carry raw PTY bytes both directions
|
||||||
//! - Text frames carry [`maestro_protocol::ClientControl`] (Resize/Ping/Pong)
|
//! - 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};
|
||||||
|
|
||||||
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 futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::ClientControl;
|
use maestro_protocol::ClientControl;
|
||||||
use serde::Deserialize;
|
use tokio::sync::broadcast;
|
||||||
use tokio::sync::broadcast::error::RecvError;
|
use tokio::sync::broadcast::error::RecvError;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::jwt::{JwtVerifier, PairAttachVerifier};
|
|
||||||
use crate::session::SessionManager;
|
use crate::session::SessionManager;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
/// Pump a WebSocket against a session's PTY, behind `WS /api/attach/:id`.
|
||||||
pub struct AttachState {
|
///
|
||||||
pub manager: SessionManager,
|
/// The subscription is acquired by the handler *before* the upgrade and handed
|
||||||
pub verifier: Arc<JwtVerifier>,
|
/// in here, so there is no window in which the session can disappear between
|
||||||
/// Pair-scoped JWT verifier (NAP-247). When the legacy verifier fails
|
/// the existence check and the subscribe.
|
||||||
/// (e.g. the token was minted as a pair-attach JWT for the broker path),
|
pub(crate) async fn serve(
|
||||||
/// 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(
|
|
||||||
socket: WebSocket,
|
socket: WebSocket,
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
replay: Vec<u8>,
|
||||||
|
mut rx: broadcast::Receiver<Vec<u8>>,
|
||||||
) -> Result<(), axum::Error> {
|
) -> Result<(), axum::Error> {
|
||||||
let (mut sink, mut stream) = socket.split();
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
|
||||||
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
|
|
||||||
// client see a clean close instead of a dropped socket.
|
|
||||||
let _ = sink.send(Message::Close(None)).await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// NAP-229: send buffered scrollback up-front so the client sees the last
|
// NAP-229: send buffered scrollback up-front so the client sees the last
|
||||||
// screenful immediately instead of a blank terminal. The replay lock the
|
// screenful immediately instead of a blank terminal. The replay lock the
|
||||||
// bridge held during snapshot+subscribe guarantees no byte appears both
|
// bridge held during snapshot+subscribe guarantees no byte appears both
|
||||||
|
|||||||
@@ -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
|
//! Clap-derive subcommand for the daemon, plus a runner that wires everything
|
||||||
//! everything the binary used to do in `main.rs`.
|
//! together.
|
||||||
//!
|
|
||||||
//! Lifted out of the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
|
|
||||||
//! binary can compose this subcommand alongside the CLI and registry.
|
|
||||||
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -15,52 +12,13 @@ use crate::sidecar::SidecarStore;
|
|||||||
use crate::tmux::Tmux;
|
use crate::tmux::Tmux;
|
||||||
use crate::worktree::WorktreeManager;
|
use crate::worktree::WorktreeManager;
|
||||||
use crate::{
|
use crate::{
|
||||||
attach, default_config_path, default_state_dir, load_config, local_api, run_configure,
|
default_config_path, default_state_dir, load_config, local_api, EnvOverrides, SessionManager,
|
||||||
spawn_session_publisher, ConfigureInputs, EnvOverrides, JwtVerifier, LocalEventBus,
|
|
||||||
PairAttachVerifier, RegistryClient, SessionManager,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum DaemonCmd {
|
pub enum DaemonCmd {
|
||||||
/// Start the daemon. Default when no subcommand is given.
|
/// Start the daemon. Default when no subcommand is given.
|
||||||
Run,
|
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
|
/// Dispatch a parsed daemon subcommand. `config_override` is the unified
|
||||||
@@ -71,7 +29,6 @@ pub async fn dispatch(
|
|||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
match cmd.unwrap_or(DaemonCmd::Run) {
|
match cmd.unwrap_or(DaemonCmd::Run) {
|
||||||
DaemonCmd::Run => run(config_override).await,
|
DaemonCmd::Run => run(config_override).await,
|
||||||
DaemonCmd::Configure(args) => configure(config_override, args),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +39,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
};
|
};
|
||||||
let config = load_config(&config_path, EnvOverrides::from_env())?;
|
let config = load_config(&config_path, EnvOverrides::from_env())?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
daemon_id = %config.daemon_id,
|
hostname = %config.hostname,
|
||||||
registry = %config.registry_url,
|
|
||||||
bind = %config.bind_addr,
|
bind = %config.bind_addr,
|
||||||
"loaded config"
|
"loaded config"
|
||||||
);
|
);
|
||||||
@@ -91,7 +47,8 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
let _ = default_state_dir();
|
let _ = default_state_dir();
|
||||||
std::fs::create_dir_all(config.sidecar_dir())?;
|
std::fs::create_dir_all(config.sidecar_dir())?;
|
||||||
std::fs::create_dir_all(config.fifo_dir())?;
|
std::fs::create_dir_all(config.fifo_dir())?;
|
||||||
std::fs::create_dir_all(&config.worktree_root)?;
|
// No worktree dir to pre-create: without an override the worktrees live
|
||||||
|
// under their base repos, created on demand (#56).
|
||||||
|
|
||||||
let sidecar = SidecarStore::new(config.sidecar_dir());
|
let sidecar = SidecarStore::new(config.sidecar_dir());
|
||||||
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
|
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
|
||||||
@@ -100,8 +57,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
None => Tmux::new(),
|
None => Tmux::new(),
|
||||||
};
|
};
|
||||||
let manager = SessionManager::new_with_replay(
|
let manager = SessionManager::new_with_replay(
|
||||||
config.daemon_id.clone(),
|
std::sync::Arc::new(tmux),
|
||||||
tmux,
|
|
||||||
sidecar,
|
sidecar,
|
||||||
worktree_mgr,
|
worktree_mgr,
|
||||||
config.fifo_dir(),
|
config.fifo_dir(),
|
||||||
@@ -116,51 +72,16 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||||
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
|
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
|
||||||
|
|
||||||
let verifier = JwtVerifier::new(&config.token, &config.daemon_id);
|
let router = local_api::router(local_api::state_for(
|
||||||
let pair_verifier = PairAttachVerifier::new(&config.token, &config.daemon_id);
|
manager,
|
||||||
|
|
||||||
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(),
|
|
||||||
config.hostname.clone(),
|
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.home_dir.clone(),
|
||||||
config.spawn_presets.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 listener = TcpListener::bind(config.bind_addr).await?;
|
||||||
let bound = listener.local_addr()?;
|
let bound = listener.local_addr()?;
|
||||||
tracing::info!(addr = %bound, "attach WS listening");
|
tracing::info!(addr = %bound, "API listening");
|
||||||
|
|
||||||
tokio::spawn(async move { client.run().await });
|
|
||||||
|
|
||||||
let shutdown = async {
|
let shutdown = async {
|
||||||
let _ = tokio::signal::ctrl_c().await;
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
@@ -172,23 +93,3 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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,27 +1,20 @@
|
|||||||
//! Daemon view over the unified Maestro config.
|
//! Daemon view over the unified Maestro config.
|
||||||
//!
|
//!
|
||||||
//! Reads `[shared]` (registry_url, token) and `[daemon]` (daemon_id,
|
//! Reads `[daemon]` (hostname, bind_addr, state_dir, worktree_root, …) and
|
||||||
//! hostname, daemon_url, bind_addr, state_dir, worktree_root) from
|
//! `[spawn]` from `~/.config/maestro/config.toml`.
|
||||||
//! `~/.config/maestro/config.toml`.
|
|
||||||
//!
|
//!
|
||||||
//! Precedence per field: **env > file > default**. The daemon refuses
|
//! Precedence per field: **env > file > default**. Every field has a default,
|
||||||
//! to start if `registry_url`, `daemon_url`, or `token` are absent
|
//! so a missing config file is fine — the daemon starts with defaults.
|
||||||
//! from both env and file.
|
|
||||||
//!
|
//!
|
||||||
//! Default config path: `~/.config/maestro/config.toml` (override via
|
//! Default config path: `~/.config/maestro/config.toml` (override via
|
||||||
//! `$MAESTRO_CONFIG`). If the file is missing entirely the daemon
|
//! `$MAESTRO_CONFIG`).
|
||||||
//! errors with a pointer at `maestrod configure` — legacy
|
|
||||||
//! `~/.config/maestrod/config.toml` is no longer read.
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use maestro_config::{derive_daemon_ws_url, MaestroConfigFile};
|
use maestro_config::MaestroConfigFile;
|
||||||
use maestro_protocol::PerimeterCandidate;
|
|
||||||
|
|
||||||
pub use maestro_config::{
|
pub use maestro_config::default_config_path;
|
||||||
default_config_path, resolve_config_path, DaemonSection, SharedSection, CONFIG_PATH_ENV,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Default bind address when neither env nor file specify one.
|
/// Default bind address when neither env nor file specify one.
|
||||||
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
|
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
|
||||||
@@ -30,20 +23,8 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
|
|||||||
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
||||||
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
|
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`.
|
/// Env var that overrides `[daemon].bind_addr`.
|
||||||
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_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`.
|
/// Env var that overrides `[daemon].tmux_tmpdir`.
|
||||||
pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR";
|
pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR";
|
||||||
|
|
||||||
@@ -57,12 +38,6 @@ pub enum ConfigError {
|
|||||||
Inner(#[from] maestro_config::ConfigError),
|
Inner(#[from] maestro_config::ConfigError),
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
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}")]
|
#[error("invalid [spawn].presets: {0}")]
|
||||||
SpawnPresets(#[from] maestro_config::SpawnConfigError),
|
SpawnPresets(#[from] maestro_config::SpawnConfigError),
|
||||||
}
|
}
|
||||||
@@ -70,26 +45,18 @@ pub enum ConfigError {
|
|||||||
/// Resolved, validated daemon configuration ready to drive the binary.
|
/// Resolved, validated daemon configuration ready to drive the binary.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DaemonConfig {
|
pub struct DaemonConfig {
|
||||||
pub daemon_id: String,
|
/// Hostname advertised to clients, for display only.
|
||||||
pub hostname: String,
|
pub hostname: String,
|
||||||
/// Home directory of the OS user the daemon runs as, advertised to clients
|
/// 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`
|
/// so they can prefill a spawn form's working directory. Empty when `$HOME`
|
||||||
/// can't be resolved (NAP-328).
|
/// can't be resolved (NAP-328).
|
||||||
pub home_dir: String,
|
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 bind_addr: SocketAddr,
|
||||||
pub daemon_url: String,
|
|
||||||
pub token: String,
|
|
||||||
pub state_dir: PathBuf,
|
pub state_dir: PathBuf,
|
||||||
pub worktree_root: PathBuf,
|
/// `[daemon].worktree_root` override (#56). `None` — the default — places
|
||||||
/// Operator-set perimeter identifier. Advertised to the registry in
|
/// worktrees inside their base repo at `<repo>/worktrees/<branch>`;
|
||||||
/// heartbeats so peers can dial this daemon over T2 when they share it.
|
/// operators who want them elsewhere set this.
|
||||||
pub perimeter_id: Option<String>,
|
pub worktree_root: Option<PathBuf>,
|
||||||
/// 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
|
/// 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.
|
/// receives this much of the prior PTY output before the live stream.
|
||||||
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
|
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
|
||||||
@@ -104,8 +71,8 @@ pub struct DaemonConfig {
|
|||||||
/// [`DEFAULT_SPAWN_HISTORY_DAYS`].
|
/// [`DEFAULT_SPAWN_HISTORY_DAYS`].
|
||||||
pub spawn_history_days: u32,
|
pub spawn_history_days: u32,
|
||||||
/// Resolved spawn-form command presets (#22/#26): `[spawn].presets` (or the
|
/// Resolved spawn-form command presets (#22/#26): `[spawn].presets` (or the
|
||||||
/// built-ins) with the implicit `custom` tile appended. Advertised to the
|
/// built-ins) with the implicit `custom` tile appended. Advertised to
|
||||||
/// web in `GET /api/web/info`.
|
/// clients in `GET /api/info`.
|
||||||
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
|
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,43 +89,13 @@ impl DaemonConfig {
|
|||||||
pub fn spawn_history_path(&self) -> PathBuf {
|
pub fn spawn_history_path(&self) -> PathBuf {
|
||||||
self.state_dir.join("spawn-history.jsonl")
|
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
|
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
||||||
/// `Option`s; production reads from real env vars in `main`.
|
/// `Option`s; production reads from real env vars in `main`.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct EnvOverrides {
|
pub struct EnvOverrides {
|
||||||
pub daemon_id: Option<String>,
|
|
||||||
pub registry_url: Option<String>,
|
|
||||||
pub bind_addr: 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>,
|
pub tmux_tmpdir: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,40 +103,17 @@ impl EnvOverrides {
|
|||||||
/// Read every supported env var from the process environment.
|
/// Read every supported env var from the process environment.
|
||||||
pub fn from_env() -> Self {
|
pub fn from_env() -> Self {
|
||||||
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(),
|
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(),
|
tmux_tmpdir: std::env::var(ENV_DAEMON_TMUX_TMPDIR).ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the runtime configuration. Applies env overrides on top of the
|
/// Resolve the runtime configuration: env overrides on top of the unified
|
||||||
/// unified file, fills defaults, validates the three required fields, and
|
/// file, then defaults. A missing file is not an error.
|
||||||
/// 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`.
|
|
||||||
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, ConfigError> {
|
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, ConfigError> {
|
||||||
let file = if path.exists() {
|
let file = MaestroConfigFile::load(path)?;
|
||||||
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 shared = file.shared;
|
|
||||||
let daemon = file.daemon;
|
let daemon = file.daemon;
|
||||||
// Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id,
|
// Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id,
|
||||||
// empty command) refuses startup with a clear error, like other config
|
// empty command) refuses startup with a clear error, like other config
|
||||||
@@ -210,32 +124,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
let home_dir = dirs::home_dir()
|
let home_dir = dirs::home_dir()
|
||||||
.map(|p| p.to_string_lossy().into_owned())
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
.unwrap_or_default();
|
.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 {
|
let bind_addr = match env.bind_addr {
|
||||||
Some(raw) => maestro_config::parse_bind_addr(&raw, ENV_DAEMON_BIND_ADDR)?,
|
Some(raw) => maestro_config::parse_bind_addr(&raw, ENV_DAEMON_BIND_ADDR)?,
|
||||||
@@ -246,12 +134,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => default_state_dir()?,
|
None => default_state_dir()?,
|
||||||
};
|
};
|
||||||
let worktree_root = daemon
|
|
||||||
.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
|
let pty_replay_bytes = daemon
|
||||||
.pty_replay_bytes
|
.pty_replay_bytes
|
||||||
.unwrap_or(crate::pty::DEFAULT_REPLAY_BYTES);
|
.unwrap_or(crate::pty::DEFAULT_REPLAY_BYTES);
|
||||||
@@ -265,17 +148,11 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
|||||||
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
|
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
|
||||||
|
|
||||||
Ok(DaemonConfig {
|
Ok(DaemonConfig {
|
||||||
daemon_id,
|
|
||||||
hostname,
|
hostname,
|
||||||
home_dir,
|
home_dir,
|
||||||
registry_url,
|
|
||||||
bind_addr,
|
bind_addr,
|
||||||
daemon_url,
|
|
||||||
token,
|
|
||||||
state_dir,
|
state_dir,
|
||||||
worktree_root,
|
worktree_root: daemon.worktree_root,
|
||||||
perimeter_id,
|
|
||||||
private_addr,
|
|
||||||
pty_replay_bytes,
|
pty_replay_bytes,
|
||||||
tmux_tmpdir,
|
tmux_tmpdir,
|
||||||
spawn_history_days,
|
spawn_history_days,
|
||||||
@@ -283,10 +160,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> {
|
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
|
||||||
Ok(maestro_config::default_state_dir("maestro")?)
|
Ok(maestro_config::default_state_dir("maestro")?)
|
||||||
}
|
}
|
||||||
@@ -307,60 +180,27 @@ pub fn system_hostname() -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn minimal_file() -> &'static str {
|
|
||||||
r#"
|
|
||||||
[shared]
|
|
||||||
registry_url = "https://r.example"
|
|
||||||
token = "abc"
|
|
||||||
|
|
||||||
[daemon]
|
|
||||||
daemon_url = "ws://localhost:7070"
|
|
||||||
"#
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn minimal_file_defaults_the_rest() {
|
fn empty_file_defaults_everything() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("config.toml");
|
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();
|
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_eq!(cfg.bind_addr, default_bind_addr());
|
||||||
assert!(cfg.state_dir.ends_with("maestro"));
|
assert!(cfg.state_dir.ends_with("maestro"));
|
||||||
assert!(cfg.worktree_root.ends_with("worktrees"));
|
assert!(cfg.worktree_root.is_none());
|
||||||
assert_eq!(cfg.daemon_id, cfg.hostname);
|
|
||||||
assert!(!cfg.hostname.is_empty());
|
assert!(!cfg.hostname.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_file_with_full_env_works() {
|
fn missing_file_is_not_an_error() {
|
||||||
// systemd / container scenario: no config file, everything in env.
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("does-not-exist.toml");
|
let path = dir.path().join("does-not-exist.toml");
|
||||||
let env = EnvOverrides {
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
registry_url: Some("http://r.example".into()),
|
assert_eq!(cfg.bind_addr, default_bind_addr());
|
||||||
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");
|
|
||||||
assert!(!path.exists(), "load_config must not create the file");
|
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]
|
#[test]
|
||||||
fn env_overrides_file() {
|
fn env_overrides_file() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -368,68 +208,24 @@ daemon_url = "ws://localhost:7070"
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&path,
|
&path,
|
||||||
r#"
|
r#"
|
||||||
[shared]
|
|
||||||
registry_url = "http://from-file"
|
|
||||||
token = "from-file"
|
|
||||||
|
|
||||||
[daemon]
|
[daemon]
|
||||||
daemon_id = "from-file"
|
|
||||||
daemon_url = "ws://from-file:1"
|
|
||||||
bind_addr = "127.0.0.1:1"
|
bind_addr = "127.0.0.1:1"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let env = EnvOverrides {
|
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()),
|
bind_addr: Some("0.0.0.0:9999".into()),
|
||||||
daemon_url: Some("ws://from-env:1".into()),
|
|
||||||
token: Some("from-env".into()),
|
|
||||||
..EnvOverrides::default()
|
..EnvOverrides::default()
|
||||||
};
|
};
|
||||||
let cfg = load_config(&path, env).unwrap();
|
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());
|
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]
|
#[test]
|
||||||
fn invalid_env_bind_addr_returns_diagnostic() {
|
fn invalid_env_bind_addr_returns_diagnostic() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("config.toml");
|
let path = dir.path().join("config.toml");
|
||||||
std::fs::write(&path, minimal_file()).unwrap();
|
std::fs::write(&path, "").unwrap();
|
||||||
let env = EnvOverrides {
|
let env = EnvOverrides {
|
||||||
bind_addr: Some("not-an-addr".into()),
|
bind_addr: Some("not-an-addr".into()),
|
||||||
..EnvOverrides::default()
|
..EnvOverrides::default()
|
||||||
@@ -447,97 +243,27 @@ daemon_url = "ws://x"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn daemon_id_defaults_to_hostname() {
|
fn hostname_from_file_wins_over_gethostname() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("config.toml");
|
let path = dir.path().join("config.toml");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&path,
|
&path,
|
||||||
r#"
|
r#"
|
||||||
[shared]
|
|
||||||
registry_url = "http://r"
|
|
||||||
token = "t"
|
|
||||||
|
|
||||||
[daemon]
|
[daemon]
|
||||||
hostname = "vm-42"
|
hostname = "vm-42"
|
||||||
daemon_url = "ws://x"
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
assert_eq!(cfg.daemon_id, "vm-42");
|
|
||||||
assert_eq!(cfg.hostname, "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]
|
#[test]
|
||||||
fn spawn_history_days_defaults_and_overrides() {
|
fn spawn_history_days_defaults_and_overrides() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("config.toml");
|
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();
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
assert_eq!(cfg.spawn_history_days, DEFAULT_SPAWN_HISTORY_DAYS);
|
assert_eq!(cfg.spawn_history_days, DEFAULT_SPAWN_HISTORY_DAYS);
|
||||||
assert!(cfg.spawn_history_path().ends_with("spawn-history.jsonl"));
|
assert!(cfg.spawn_history_path().ends_with("spawn-history.jsonl"));
|
||||||
@@ -545,12 +271,7 @@ private_addr = "10.0.0.7:7070"
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&path,
|
&path,
|
||||||
r#"
|
r#"
|
||||||
[shared]
|
|
||||||
registry_url = "http://r"
|
|
||||||
token = "t"
|
|
||||||
|
|
||||||
[daemon]
|
[daemon]
|
||||||
daemon_url = "ws://x"
|
|
||||||
spawn_history_days = 0
|
spawn_history_days = 0
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -576,12 +297,7 @@ spawn_history_days = 0
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&path,
|
&path,
|
||||||
r#"
|
r#"
|
||||||
[shared]
|
|
||||||
registry_url = "http://r"
|
|
||||||
token = "t"
|
|
||||||
|
|
||||||
[daemon]
|
[daemon]
|
||||||
daemon_url = "ws://x"
|
|
||||||
tmux_tmpdir = "/from-file"
|
tmux_tmpdir = "/from-file"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -601,12 +317,7 @@ tmux_tmpdir = "/from-file"
|
|||||||
std::fs::write(
|
std::fs::write(
|
||||||
&path,
|
&path,
|
||||||
r#"
|
r#"
|
||||||
[shared]
|
|
||||||
registry_url = "http://r"
|
|
||||||
token = "t"
|
|
||||||
|
|
||||||
[daemon]
|
[daemon]
|
||||||
daemon_url = "ws://x"
|
|
||||||
tmux_tmpdir = "/from-file"
|
tmux_tmpdir = "/from-file"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -619,7 +330,7 @@ tmux_tmpdir = "/from-file"
|
|||||||
fn tmux_tmpdir_defaults_to_xdg_runtime_dir_when_unset() {
|
fn tmux_tmpdir_defaults_to_xdg_runtime_dir_when_unset() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let path = dir.path().join("config.toml");
|
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();
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
// dirs::runtime_dir() is `Some(_)` on any Linux host with
|
// dirs::runtime_dir() is `Some(_)` on any Linux host with
|
||||||
// $XDG_RUNTIME_DIR set (CI included). When it's `None`, the daemon
|
// $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`
|
//! - relative `a/b` → home + `a/b`
|
||||||
//! - absolute `/a/b` → as-is
|
//! - 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.
|
//! just wraps its `Result` in an `RpcResult` for the wire.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//! to its worktree toplevel.
|
//! to its worktree toplevel.
|
||||||
//!
|
//!
|
||||||
//! It's a pure filesystem walk with no `git` subprocess — same shape as
|
//! 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.
|
//! HTTP layer (#26) just wraps its `Option` for the wire.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
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,27 @@
|
|||||||
//! `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
|
//! Wraps tmux, owns every session on the machine, and serves them to clients
|
||||||
//! component's responsibilities. This library exposes the pieces; the binary
|
//! (CLI, TUI, web) over a plain HTTP + WebSocket API on a local bind address.
|
||||||
//! entry point in `src/main.rs` wires them together.
|
//! No registry, no peers, no auth layer: reaching the bind address is the
|
||||||
|
//! authorization.
|
||||||
|
|
||||||
#![deny(missing_debug_implementations)]
|
#![deny(missing_debug_implementations)]
|
||||||
|
|
||||||
pub mod activity;
|
pub mod activity;
|
||||||
pub mod attach;
|
pub mod attach;
|
||||||
pub mod broker;
|
|
||||||
pub mod catalog_mirror;
|
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod configure;
|
|
||||||
pub mod dirlist;
|
pub mod dirlist;
|
||||||
pub mod gitroot;
|
pub mod gitroot;
|
||||||
pub mod jwt;
|
|
||||||
pub mod local_api;
|
pub mod local_api;
|
||||||
pub mod local_events;
|
pub mod panes;
|
||||||
pub mod proc;
|
pub mod proc;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod registry_client;
|
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod spawn_history;
|
pub mod spawn_history;
|
||||||
pub mod tmux;
|
pub mod tmux;
|
||||||
pub mod worktree;
|
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 config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
|
||||||
pub use configure::{run as run_configure, ConfigureError, ConfigureInputs};
|
pub use session::{SessionError, SessionManager};
|
||||||
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
|
//! These routes live on the daemon's `bind_addr` (default `127.0.0.1:7070`).
|
||||||
//! `127.0.0.1:7070`) alongside the legacy `/sessions/:id/stream` WS endpoint.
|
//! They're the contract `maestro-web`, the TUI, and the CLI call against.
|
||||||
//! 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.
|
|
||||||
//!
|
//!
|
||||||
//! ## Trust model (v1)
|
//! ## Trust model
|
||||||
//!
|
//!
|
||||||
//! Whoever can reach `bind_addr` can use these routes. No JWT, no cookie. The
|
//! 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).
|
//! 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
|
//! Operators who bind a LAN/VPN address opt into one-segment-wide trust — the
|
||||||
//! trust — same model `maestro-web` documents per the connection-orchestration
|
//! perimeter is theirs to choose, Maestro does not police it.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -32,112 +19,57 @@ use axum::routing::{any, delete, get};
|
|||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use maestro_config::SpawnPreset;
|
use maestro_config::SpawnPreset;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, Session,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::catalog_mirror::CatalogMirror;
|
|
||||||
use crate::local_events::LocalEventBus;
|
|
||||||
use crate::session::SessionManager;
|
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).
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct WebInfo {
|
|
||||||
pub daemon_id: String,
|
|
||||||
pub hostname: String,
|
|
||||||
pub version: String,
|
|
||||||
pub daemon_url: String,
|
|
||||||
/// Spawn-form command presets resolved from `[spawn].presets` (#22), with
|
|
||||||
/// the implicit `custom` tile appended. The web renders these as one-click
|
|
||||||
/// tiles; it rides this existing payload so opening the form needs no extra
|
|
||||||
/// round-trip (#26).
|
|
||||||
pub presets: Vec<SpawnPreset>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LocalApiState {
|
pub struct LocalApiState {
|
||||||
pub manager: SessionManager,
|
pub manager: SessionManager,
|
||||||
pub hostname: Arc<String>,
|
pub hostname: Arc<String>,
|
||||||
pub daemon_url: Arc<String>,
|
|
||||||
/// Home directory of the OS user this daemon runs as, advertised so clients
|
/// Home directory of the OS user this daemon runs as, advertised so clients
|
||||||
/// can prefill a spawn form's working directory. Empty if unresolved.
|
/// can prefill a spawn form's working directory. Empty if unresolved.
|
||||||
pub home_dir: Arc<String>,
|
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>>,
|
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.
|
|
||||||
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 {
|
pub fn router(state: LocalApiState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/sessions", get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route("/api/sessions/:id", delete(kill_session))
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
.route("/api/daemons", get(list_daemons))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route("/api/daemons/:id/dirs", get(list_dir))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
.route("/api/daemons/:id/recent-dirs", get(recent_dirs))
|
.route(routes::GITROOT, get(git_root))
|
||||||
.route("/api/daemons/:id/gitroot", get(git_root))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route("/api/web/info", get(web_info))
|
.route(routes::EVENTS, any(events_ws))
|
||||||
.route("/api/events", any(events_ws))
|
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_sessions(State(s): State<LocalApiState>) -> Json<Vec<Session>> {
|
async fn list_sessions(State(s): State<LocalApiState>) -> Json<Vec<Session>> {
|
||||||
let mut out = s.manager.snapshot().await;
|
Json(s.manager.snapshot().await)
|
||||||
out.extend(s.catalog.sessions().await);
|
|
||||||
Json(out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_daemons(State(s): State<LocalApiState>) -> Json<Vec<DaemonInfo>> {
|
/// Query string for `GET /api/dirs`. `path` is the directory to list (the
|
||||||
let self_info = DaemonInfo {
|
/// parent of what the user is typing); empty/`~`/relative are resolved against
|
||||||
daemon_id: s.manager.daemon_id().to_string(),
|
/// the daemon's home dir.
|
||||||
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.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct DirQuery {
|
struct DirQuery {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List one directory's immediate subdirectories on `daemon_id`'s host, for the
|
/// List one directory's immediate subdirectories, for the spawn form's
|
||||||
/// spawn form's working-directory autocomplete (NAP-329). Lists locally when
|
/// working-directory autocomplete (NAP-329).
|
||||||
/// the target is this daemon; otherwise brokers a `ListDir` to the owning
|
async fn list_dir(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) -> Response {
|
||||||
/// daemon over the registry control link.
|
match crate::dirlist::list_dir(&s.home_dir, &q.path) {
|
||||||
async fn list_dir(
|
Ok(listing) => Json(listing).into_response(),
|
||||||
State(s): State<LocalApiState>,
|
Err(info) => error_response(dir_error_status(&info.code), &info.code, &info.message),
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,48 +83,37 @@ fn dir_error_status(code: &str) -> StatusCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recent working dirs on `daemon_id`'s host for the spawn form's recents
|
/// HTTP status for a [`crate::session::SessionError`] code. Everything used to
|
||||||
/// picker (#26). Reads locally when the target is this daemon; otherwise brokers
|
/// collapse to 502, which reports a client mistake as a daemon failure.
|
||||||
/// a `RecentDirs` query to the owning daemon over the registry control link.
|
fn session_error_status(code: &str) -> StatusCode {
|
||||||
async fn recent_dirs(State(s): State<LocalApiState>, Path(daemon_id): Path<String>) -> Response {
|
match code {
|
||||||
if daemon_id == s.manager.daemon_id() {
|
"not_found" => StatusCode::NOT_FOUND,
|
||||||
let value = crate::spawn_history::to_wire(s.manager.recent_dirs());
|
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||||
return Json(value).into_response();
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
|
||||||
match s.broker.forward_recent_dirs(daemon_id).await {
|
|
||||||
Ok(recents) => Json::<RecentDirs>(recents).into_response(),
|
|
||||||
Err(e) => broker_error_response(&e),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe the enclosing git repo of `path` on `daemon_id`'s host, to infer the
|
/// Recent working dirs for the spawn form's recents picker (#26).
|
||||||
/// worktree `base_repo` for the spawn form (#26). Local when the target is this
|
async fn recent_dirs(State(s): State<LocalApiState>) -> Response {
|
||||||
/// daemon; otherwise brokered to the owning daemon. `path` is resolved against
|
Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response()
|
||||||
/// 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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn web_info(State(s): State<LocalApiState>) -> Json<WebInfo> {
|
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
|
||||||
Json(WebInfo {
|
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
|
||||||
daemon_id: s.manager.daemon_id().to_string(),
|
/// (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 daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
||||||
|
Json(DaemonInfo {
|
||||||
hostname: (*s.hostname).clone(),
|
hostname: (*s.hostname).clone(),
|
||||||
version: PROTOCOL_VERSION.into(),
|
version: PROTOCOL_VERSION.into(),
|
||||||
daemon_url: (*s.daemon_url).clone(),
|
home_dir: (*s.home_dir).clone(),
|
||||||
presets: (*s.presets).clone(),
|
presets: (*s.presets).clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -201,20 +122,12 @@ async fn create_session(
|
|||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
Json(body): Json<CreateSessionRequest>,
|
Json(body): Json<CreateSessionRequest>,
|
||||||
) -> Response {
|
) -> 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 {
|
match s.manager.create(body).await {
|
||||||
Ok(session) => (StatusCode::CREATED, Json(session)).into_response(),
|
Ok(session) => (StatusCode::CREATED, Json(session)).into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let info = e.to_error_info();
|
let info = e.to_error_info();
|
||||||
error_response(
|
error_response(
|
||||||
StatusCode::BAD_GATEWAY,
|
session_error_status(&info.code),
|
||||||
info.code.as_str(),
|
info.code.as_str(),
|
||||||
info.message.as_str(),
|
info.message.as_str(),
|
||||||
)
|
)
|
||||||
@@ -222,36 +135,16 @@ async fn create_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `kill` already reports a missing session as `SessionError::NotFound`, which
|
||||||
|
/// maps to 404. Checking first would only add a window in which the liveness
|
||||||
|
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
|
||||||
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
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",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
match s.manager.kill(id).await {
|
match s.manager.kill(id).await {
|
||||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let info = e.to_error_info();
|
let info = e.to_error_info();
|
||||||
error_response(
|
error_response(
|
||||||
StatusCode::BAD_GATEWAY,
|
session_error_status(&info.code),
|
||||||
info.code.as_str(),
|
info.code.as_str(),
|
||||||
info.message.as_str(),
|
info.message.as_str(),
|
||||||
)
|
)
|
||||||
@@ -259,69 +152,55 @@ 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 {
|
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||||
(
|
(status, Json(ApiError::new(code, message))).into_response()
|
||||||
status,
|
|
||||||
Json(serde_json::json!({
|
|
||||||
"error": ErrorInfo {
|
|
||||||
code: code.into(),
|
|
||||||
message: message.into(),
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||||
/// `DaemonConfig` so callers don't have to assemble it by hand.
|
/// `DaemonConfig` so callers don't have to assemble it by hand.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn state_for(
|
pub fn state_for(
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
hostname: impl Into<String>,
|
hostname: impl Into<String>,
|
||||||
daemon_url: impl Into<String>,
|
|
||||||
home_dir: impl Into<String>,
|
home_dir: impl Into<String>,
|
||||||
presets: Vec<SpawnPreset>,
|
presets: Vec<SpawnPreset>,
|
||||||
catalog: CatalogMirror,
|
|
||||||
events: LocalEventBus,
|
|
||||||
broker: BrokerHandle,
|
|
||||||
) -> LocalApiState {
|
) -> LocalApiState {
|
||||||
LocalApiState {
|
LocalApiState {
|
||||||
manager,
|
manager,
|
||||||
hostname: Arc::new(hostname.into()),
|
hostname: Arc::new(hostname.into()),
|
||||||
daemon_url: Arc::new(daemon_url.into()),
|
|
||||||
home_dir: Arc::new(home_dir.into()),
|
home_dir: Arc::new(home_dir.into()),
|
||||||
presets: Arc::new(presets),
|
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)]
|
||||||
|
/// Subscribing *before* the upgrade makes the 404 and the subscription one
|
||||||
|
/// atomic step. Checking existence and then subscribing inside the callback
|
||||||
|
/// left a window in which the liveness watcher could reap the session, so a
|
||||||
|
/// client that passed the check still got a socket that immediately closed.
|
||||||
|
async fn attach_ws(
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
ws: axum::extract::WebSocketUpgrade,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
||||||
|
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, replay, rx).await;
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async fn events_ws(
|
async fn events_ws(
|
||||||
@@ -339,7 +218,6 @@ async fn pump_events(
|
|||||||
) -> Result<(), axum::Error> {
|
) -> Result<(), axum::Error> {
|
||||||
use axum::extract::ws::Message;
|
use axum::extract::ws::Message;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::RegistryEvent;
|
|
||||||
use tokio::sync::broadcast::error::RecvError;
|
use tokio::sync::broadcast::error::RecvError;
|
||||||
|
|
||||||
let (mut sink, mut stream) = socket.split();
|
let (mut sink, mut stream) = socket.split();
|
||||||
@@ -347,20 +225,11 @@ async fn pump_events(
|
|||||||
// Subscribe BEFORE building the snapshot so deltas applied while we're
|
// Subscribe BEFORE building the snapshot so deltas applied while we're
|
||||||
// marshaling can't slip between the two and disappear. The UI is
|
// marshaling can't slip between the two and disappear. The UI is
|
||||||
// idempotent for repeated *Added events, so any overlap is harmless.
|
// idempotent for repeated *Added events, so any overlap is harmless.
|
||||||
let mut rx = s.events.subscribe();
|
let mut rx = s.manager.events();
|
||||||
|
|
||||||
let self_info = DaemonInfo {
|
let snapshot = CatalogEvent::Snapshot {
|
||||||
daemon_id: s.manager.daemon_id().to_string(),
|
sessions: s.manager.snapshot().await,
|
||||||
hostname: (*s.hostname).clone(),
|
|
||||||
version: PROTOCOL_VERSION.into(),
|
|
||||||
daemon_url: (*s.daemon_url).clone(),
|
|
||||||
home_dir: (*s.home_dir).clone(),
|
|
||||||
};
|
};
|
||||||
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");
|
let text = serde_json::to_string(&snapshot).expect("snapshot encode");
|
||||||
if sink.send(Message::Text(text)).await.is_err() {
|
if sink.send(Message::Text(text)).await.is_err() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -371,7 +240,7 @@ async fn pump_events(
|
|||||||
ev = rx.recv() => {
|
ev = rx.recv() => {
|
||||||
let event = match ev {
|
let event = match ev {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(RecvError::Lagged(n)) => RegistryEvent::Lagged { skipped: n },
|
Err(RecvError::Lagged(n)) => CatalogEvent::Lagged { skipped: n },
|
||||||
Err(RecvError::Closed) => break,
|
Err(RecvError::Closed) => break,
|
||||||
};
|
};
|
||||||
let text = serde_json::to_string(&event).expect("event encode");
|
let text = serde_json::to_string(&event).expect("event encode");
|
||||||
@@ -393,205 +262,3 @@ async fn pump_events(
|
|||||||
}
|
}
|
||||||
Ok(())
|
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,126 +0,0 @@
|
|||||||
//! Daemon-local fan-out of `RegistryEvent`s 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.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use maestro_protocol::RegistryEvent;
|
|
||||||
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).
|
|
||||||
const BUS_CAPACITY: usize = maestro_config::BroadcastTuning::DEFAULT.events_capacity;
|
|
||||||
|
|
||||||
/// Cheap-to-clone handle around a `broadcast::Sender<RegistryEvent>`.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct LocalEventBus {
|
|
||||||
sender: Arc<broadcast::Sender<RegistryEvent>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocalEventBus {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let (sender, _) = broadcast::channel(BUS_CAPACITY);
|
|
||||||
Self {
|
|
||||||
sender: Arc::new(sender),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push an event onto the bus. No-op if there are no subscribers.
|
|
||||||
pub fn publish(&self, event: RegistryEvent) {
|
|
||||||
let _ = self.sender.send(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<RegistryEvent> {
|
|
||||||
self.sender.subscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LocalEventBus {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn a task that converts each `SessionEvent` from `manager` into the
|
|
||||||
/// matching `RegistryEvent` 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 });
|
|
||||||
}
|
|
||||||
Ok(SessionEvent::Updated(session)) => {
|
|
||||||
bus.publish(RegistryEvent::SessionUpdated { session });
|
|
||||||
}
|
|
||||||
Ok(SessionEvent::Removed(id)) => {
|
|
||||||
bus.publish(RegistryEvent::SessionRemoved {
|
|
||||||
daemon_id: daemon_id.clone(),
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
|
||||||
bus.publish(RegistryEvent::Lagged { skipped });
|
|
||||||
}
|
|
||||||
Err(broadcast::error::RecvError::Closed) => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn publish_visible_to_subscriber() {
|
|
||||||
let bus = LocalEventBus::new();
|
|
||||||
let mut rx = bus.subscribe();
|
|
||||||
bus.publish(RegistryEvent::Lagged { skipped: 7 });
|
|
||||||
match rx.recv().await {
|
|
||||||
Ok(RegistryEvent::Lagged { skipped }) => assert_eq!(skipped, 7),
|
|
||||||
other => panic!("unexpected: {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn clones_share_state() {
|
|
||||||
let a = LocalEventBus::new();
|
|
||||||
let b = a.clone();
|
|
||||||
let mut rx = a.subscribe();
|
|
||||||
b.publish(RegistryEvent::Lagged { skipped: 3 });
|
|
||||||
match rx.recv().await {
|
|
||||||
Ok(RegistryEvent::Lagged { skipped }) => assert_eq!(skipped, 3),
|
|
||||||
other => panic!("unexpected: {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
//! The tmux operations [`crate::session::SessionManager`] depends on.
|
||||||
|
//!
|
||||||
|
//! Behind a trait so the manager's *decisions* — spawn rollback ordering,
|
||||||
|
//! liveness diffing, activity change-detection — can be exercised without
|
||||||
|
//! forking a tmux server. [`Tmux`] is the production adapter; [`FakePanes`] is
|
||||||
|
//! the one tests drive.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::tmux::{PaneInfo, Tmux, TmuxError};
|
||||||
|
|
||||||
|
/// Every tmux call the session manager makes.
|
||||||
|
///
|
||||||
|
/// Object-safe on purpose: `Inner` holds an `Arc<dyn Panes>` rather than a type
|
||||||
|
/// parameter, which would otherwise spread through `LocalApiState`, `attach`
|
||||||
|
/// and `cli`. One vtable hop is free next to a `fork`+`exec`.
|
||||||
|
pub trait Panes: std::fmt::Debug + Send + Sync + 'static {
|
||||||
|
fn new_session_piped(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
cwd: &Path,
|
||||||
|
command: &str,
|
||||||
|
session_env: &BTreeMap<String, String>,
|
||||||
|
sink_command: &str,
|
||||||
|
) -> Result<(), TmuxError>;
|
||||||
|
|
||||||
|
fn kill_session(&self, id: Uuid) -> Result<(), TmuxError>;
|
||||||
|
|
||||||
|
fn list_sessions(&self) -> Result<Vec<Uuid>, TmuxError>;
|
||||||
|
|
||||||
|
fn pane_info(&self, id: Uuid) -> Result<Option<PaneInfo>, TmuxError>;
|
||||||
|
|
||||||
|
fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), TmuxError>;
|
||||||
|
|
||||||
|
fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError>;
|
||||||
|
|
||||||
|
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panes for Tmux {
|
||||||
|
fn new_session_piped(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
cwd: &Path,
|
||||||
|
command: &str,
|
||||||
|
session_env: &BTreeMap<String, String>,
|
||||||
|
sink_command: &str,
|
||||||
|
) -> Result<(), TmuxError> {
|
||||||
|
Tmux::new_session_piped(self, id, cwd, command, session_env, sink_command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kill_session(&self, id: Uuid) -> Result<(), TmuxError> {
|
||||||
|
Tmux::kill_session(self, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_sessions(&self) -> Result<Vec<Uuid>, TmuxError> {
|
||||||
|
Tmux::list_sessions(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pane_info(&self, id: Uuid) -> Result<Option<PaneInfo>, TmuxError> {
|
||||||
|
Tmux::pane_info(self, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), TmuxError> {
|
||||||
|
Tmux::send_keys(self, id, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError> {
|
||||||
|
Tmux::pipe_pane(self, id, sink_command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
|
||||||
|
Tmux::resize_window(self, id, cols, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test adapter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Which [`Panes`] call a [`FakePanes`] expectation targets.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Op {
|
||||||
|
NewSessionPiped,
|
||||||
|
KillSession,
|
||||||
|
ListSessions,
|
||||||
|
PaneInfo,
|
||||||
|
SendKeys,
|
||||||
|
PipePane,
|
||||||
|
ResizeWindow,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One recorded call, in the order the manager made it. Rollback ordering is a
|
||||||
|
/// sequence property, so the tests that matter assert on this.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Call {
|
||||||
|
NewSessionPiped {
|
||||||
|
id: Uuid,
|
||||||
|
command: String,
|
||||||
|
env: BTreeMap<String, String>,
|
||||||
|
},
|
||||||
|
KillSession(Uuid),
|
||||||
|
ListSessions,
|
||||||
|
PaneInfo(Uuid),
|
||||||
|
SendKeys {
|
||||||
|
id: Uuid,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
},
|
||||||
|
PipePane(Uuid),
|
||||||
|
ResizeWindow {
|
||||||
|
id: Uuid,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory [`Panes`] for tests.
|
||||||
|
///
|
||||||
|
/// Lives in `src/` rather than a test module because the daemon's integration
|
||||||
|
/// tests are separate crates, which `#[cfg(test)]` cannot reach and which
|
||||||
|
/// cannot enable a feature on their own crate.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct FakePanes {
|
||||||
|
state: Mutex<FakeState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct FakeState {
|
||||||
|
live: HashSet<Uuid>,
|
||||||
|
panes: HashMap<Uuid, PaneInfo>,
|
||||||
|
calls: Vec<Call>,
|
||||||
|
fail: HashMap<Op, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakePanes {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make every call to `op` fail with `stderr`.
|
||||||
|
pub fn fail(&self, op: Op, stderr: impl Into<String>) {
|
||||||
|
self.lock().fail.insert(op, stderr.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pretend a session exists without the manager having created it — the
|
||||||
|
/// recovery path's starting condition.
|
||||||
|
pub fn add_live(&self, id: Uuid) {
|
||||||
|
self.lock().live.insert(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a session behind the manager's back, the way a command exiting or
|
||||||
|
/// an external `tmux kill-session` would.
|
||||||
|
pub fn remove_live(&self, id: Uuid) {
|
||||||
|
self.lock().live.remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_live(&self, id: Uuid) -> bool {
|
||||||
|
self.lock().live.contains(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_pane_info(&self, id: Uuid, info: PaneInfo) {
|
||||||
|
self.lock().panes.insert(id, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every call so far, in order.
|
||||||
|
pub fn calls(&self) -> Vec<Call> {
|
||||||
|
self.lock().calls.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> std::sync::MutexGuard<'_, FakeState> {
|
||||||
|
self.state.lock().expect("FakePanes mutex poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&self, call: Call, op: Op) -> Result<(), TmuxError> {
|
||||||
|
let mut st = self.lock();
|
||||||
|
st.calls.push(call);
|
||||||
|
match st.fail.get(&op) {
|
||||||
|
Some(stderr) => Err(TmuxError::NonZero {
|
||||||
|
code: 1,
|
||||||
|
stderr: stderr.clone(),
|
||||||
|
}),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panes for FakePanes {
|
||||||
|
fn new_session_piped(
|
||||||
|
&self,
|
||||||
|
id: Uuid,
|
||||||
|
_cwd: &Path,
|
||||||
|
command: &str,
|
||||||
|
session_env: &BTreeMap<String, String>,
|
||||||
|
_sink_command: &str,
|
||||||
|
) -> Result<(), TmuxError> {
|
||||||
|
self.record(
|
||||||
|
Call::NewSessionPiped {
|
||||||
|
id,
|
||||||
|
command: command.to_owned(),
|
||||||
|
env: session_env.clone(),
|
||||||
|
},
|
||||||
|
Op::NewSessionPiped,
|
||||||
|
)?;
|
||||||
|
self.lock().live.insert(id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kill_session(&self, id: Uuid) -> Result<(), TmuxError> {
|
||||||
|
self.record(Call::KillSession(id), Op::KillSession)?;
|
||||||
|
self.lock().live.remove(&id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_sessions(&self) -> Result<Vec<Uuid>, TmuxError> {
|
||||||
|
self.record(Call::ListSessions, Op::ListSessions)?;
|
||||||
|
Ok(self.lock().live.iter().copied().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pane_info(&self, id: Uuid) -> Result<Option<PaneInfo>, TmuxError> {
|
||||||
|
self.record(Call::PaneInfo(id), Op::PaneInfo)?;
|
||||||
|
Ok(self.lock().panes.get(&id).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), TmuxError> {
|
||||||
|
self.record(
|
||||||
|
Call::SendKeys {
|
||||||
|
id,
|
||||||
|
bytes: bytes.to_vec(),
|
||||||
|
},
|
||||||
|
Op::SendKeys,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pipe_pane(&self, id: Uuid, _sink_command: &str) -> Result<(), TmuxError> {
|
||||||
|
self.record(Call::PipePane(id), Op::PipePane)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_window(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), TmuxError> {
|
||||||
|
self.record(Call::ResizeWindow { id, cols, rows }, Op::ResizeWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_calls_in_order_and_tracks_liveness() {
|
||||||
|
let fake = FakePanes::new();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
|
||||||
|
fake.new_session_piped(id, Path::new("/tmp"), "bash", &BTreeMap::new(), "cat >> /x")
|
||||||
|
.unwrap();
|
||||||
|
assert!(fake.is_live(id));
|
||||||
|
fake.kill_session(id).unwrap();
|
||||||
|
assert!(!fake.is_live(id));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fake.calls(),
|
||||||
|
vec![
|
||||||
|
Call::NewSessionPiped {
|
||||||
|
id,
|
||||||
|
command: "bash".into(),
|
||||||
|
env: BTreeMap::new(),
|
||||||
|
},
|
||||||
|
Call::KillSession(id),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn injected_failure_still_records_the_attempt() {
|
||||||
|
let fake = FakePanes::new();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
fake.fail(Op::NewSessionPiped, "boom");
|
||||||
|
|
||||||
|
assert!(fake
|
||||||
|
.new_session_piped(id, Path::new("/tmp"), "bash", &BTreeMap::new(), "cat")
|
||||||
|
.is_err());
|
||||||
|
assert!(
|
||||||
|
!fake.is_live(id),
|
||||||
|
"a failed spawn must not register a session"
|
||||||
|
);
|
||||||
|
assert_eq!(fake.calls().len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Best-effort `/proc` inspection for the activity watcher (NAP-340, Linux).
|
//! Best-effort `/proc` inspection for the activity watcher (NAP-340, Linux).
|
||||||
//!
|
//!
|
||||||
//! Decides whether a session is **blocked waiting on the user**: walk from the
|
//! Decides whether a session is **blocked waiting on the user**: ask the kernel
|
||||||
//! pane's process down to the deepest descendant (the agent/shell actually on
|
//! which process group owns the terminal, then check whether any live member of
|
||||||
//! the terminal) and check it's parked waiting on terminal input.
|
//! that group is parked on the terminal.
|
||||||
//!
|
//!
|
||||||
//! `/proc/<pid>/syscall` would say so exactly, but it's ptrace-gated and
|
//! `/proc/<pid>/syscall` would say so exactly, but it's ptrace-gated and
|
||||||
//! unreadable for our own sessions on a default kernel, so we combine three
|
//! unreadable for our own sessions on a default kernel, so we combine three
|
||||||
@@ -16,55 +16,120 @@
|
|||||||
//! Every step degrades to `false` on a read failure, so a non-Linux host or a
|
//! Every step degrades to `false` on a read failure, so a non-Linux host or a
|
||||||
//! denied `/proc` read falls back to `Idle`.
|
//! denied `/proc` read falls back to `Idle`.
|
||||||
|
|
||||||
/// Whether the process tree under `pane_pid` looks blocked on terminal input
|
use std::collections::HashMap;
|
||||||
/// from `tty` (e.g. `/dev/pts/3`) — the signal for `AwaitingInput`.
|
|
||||||
pub fn awaiting_input(pane_pid: i32, tty: &str) -> bool {
|
/// One walk of `/proc`, shared by every session in an activity pass.
|
||||||
let leaf = deepest_descendant(pane_pid);
|
///
|
||||||
|
/// The foreground process group is the unit that matters, and its members are
|
||||||
|
/// not reliably reachable from the pane: the group leader can exit while others
|
||||||
|
/// run on, and a stage that forks a terminal-reading child and exits leaves
|
||||||
|
/// that child reparented to init. Walking the pane's process tree misses both.
|
||||||
|
///
|
||||||
|
/// Snapshotting once per pass costs one `/proc` walk however many sessions the
|
||||||
|
/// daemon owns — cheaper than the per-session `tmux list-panes` fork that
|
||||||
|
/// already runs on the same tick.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ProcGroups {
|
||||||
|
stats: HashMap<i32, Stat>,
|
||||||
|
by_pgid: HashMap<i32, Vec<i32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcGroups {
|
||||||
|
pub fn snapshot() -> Self {
|
||||||
|
let mut stats = HashMap::new();
|
||||||
|
let mut by_pgid: HashMap<i32, Vec<i32>> = HashMap::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||||
|
return Self::default();
|
||||||
|
};
|
||||||
|
for pid in entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| e.file_name().to_str()?.parse::<i32>().ok())
|
||||||
|
{
|
||||||
|
// A process can exit mid-walk; skipping it is the correct answer.
|
||||||
|
if let Some(stat) = read_stat(pid) {
|
||||||
|
by_pgid.entry(stat.pgrp).or_default().push(pid);
|
||||||
|
stats.insert(pid, stat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self { stats, by_pgid }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process group the terminal considers foreground, per `tpgid`.
|
||||||
|
fn foreground_pgid(&self, pane_pid: i32) -> Option<i32> {
|
||||||
|
let tpgid = self.stats.get(&pane_pid)?.tpgid;
|
||||||
|
// -1 when the process has no controlling terminal.
|
||||||
|
(tpgid > 0).then_some(tpgid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn members(&self, pgid: i32) -> &[i32] {
|
||||||
|
self.by_pgid.get(&pgid).map_or(&[], Vec::as_slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self, pid: i32) -> Option<char> {
|
||||||
|
self.stats.get(&pid).map(|s| s.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the session on `tty` (e.g. `/dev/pts/3`) is blocked waiting on the
|
||||||
|
/// user — the signal for `AwaitingInput`.
|
||||||
|
///
|
||||||
|
/// True when *any* member of the terminal's foreground group is parked on it: a
|
||||||
|
/// group can hold a busy or timer-sleeping stage alongside the one actually
|
||||||
|
/// reading, and either can be the reparented one.
|
||||||
|
pub fn awaiting_input(pane_pid: i32, tty: &str, groups: &ProcGroups) -> bool {
|
||||||
|
let Some(pgid) = groups.foreground_pgid(pane_pid) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
groups
|
||||||
|
.members(pgid)
|
||||||
|
.iter()
|
||||||
|
.any(|pid| parked_on_tty(*pid, tty, groups))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `pid` is parked on `tty` rather than busy or sleeping on a timer.
|
||||||
|
fn parked_on_tty(pid: i32, tty: &str, groups: &ProcGroups) -> bool {
|
||||||
// Interruptible sleep is where a blocking read parks; anything else
|
// Interruptible sleep is where a blocking read parks; anything else
|
||||||
// (running, uninterruptible) is busy, not waiting on us.
|
// (running, uninterruptible) is busy, not waiting on us.
|
||||||
if read_state(leaf) != Some('S') {
|
if groups.state(pid) != Some('S') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// A pure timer sleep (`sleep`, `usleep`) also sits in 'S' with the terminal
|
// A pure timer sleep (`sleep`, `usleep`) also sits in 'S' with the terminal
|
||||||
// inherited — exclude it so it reads as Idle, not AwaitingInput.
|
// inherited — exclude it so it reads as Idle, not AwaitingInput.
|
||||||
if is_timer_sleep(leaf) {
|
if is_timer_sleep(pid) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
fd_points_at_tty(leaf, tty)
|
fd_points_at_tty(pid, tty)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follow the (last) child link down the process tree to the deepest
|
/// The fields of `/proc/<pid>/stat` this module needs.
|
||||||
/// descendant. Bounded so a pathological/cyclic tree can't loop.
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
fn deepest_descendant(pid: i32) -> i32 {
|
struct Stat {
|
||||||
let mut cur = pid;
|
state: char,
|
||||||
for _ in 0..64 {
|
/// The process group this process belongs to.
|
||||||
match last_child(cur) {
|
pgrp: i32,
|
||||||
Some(child) if child != cur => cur = child,
|
/// Foreground process group of the controlling terminal.
|
||||||
_ => break,
|
tpgid: i32,
|
||||||
}
|
|
||||||
}
|
|
||||||
cur
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The most-recently-created child of `pid`, via
|
fn read_stat(pid: i32) -> Option<Stat> {
|
||||||
/// `/proc/<pid>/task/<pid>/children` (a space-separated pid list). The last
|
parse_stat(&std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)
|
||||||
/// entry is the newest child — typically the foreground one an agent spawned.
|
|
||||||
fn last_child(pid: i32) -> Option<i32> {
|
|
||||||
let contents = std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children")).ok()?;
|
|
||||||
contents.split_whitespace().last()?.parse().ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the process state char from `/proc/<pid>/stat`.
|
/// Parse the fields after `comm` from a `/proc/<pid>/stat` line.
|
||||||
fn read_state(pid: i32) -> Option<char> {
|
///
|
||||||
parse_stat_state(&std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)
|
/// The format is `pid (comm) state ppid pgrp session tty_nr tpgid ...`, and
|
||||||
}
|
/// `comm` can itself contain spaces and parens — so everything is indexed from
|
||||||
|
/// the *final* `)`, never by splitting the whole line.
|
||||||
/// Extract the state field from a `/proc/<pid>/stat` line. The format is
|
fn parse_stat(stat: &str) -> Option<Stat> {
|
||||||
/// `pid (comm) STATE ...`; `comm` can itself contain spaces and parens, so the
|
|
||||||
/// state is the first non-space char *after the final `)`*.
|
|
||||||
fn parse_stat_state(stat: &str) -> Option<char> {
|
|
||||||
let close = stat.rfind(')')?;
|
let close = stat.rfind(')')?;
|
||||||
stat[close + 1..].trim_start().chars().next()
|
let mut rest = stat[close + 1..].split_whitespace();
|
||||||
|
let state = rest.next()?.chars().next()?;
|
||||||
|
let _ppid = rest.next()?;
|
||||||
|
let pgrp = rest.next()?.parse().ok()?;
|
||||||
|
let _session = rest.next()?;
|
||||||
|
let _tty_nr = rest.next()?;
|
||||||
|
let tpgid = rest.next()?.parse().ok()?;
|
||||||
|
Some(Stat { state, pgrp, tpgid })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `pid`'s `wchan` (the kernel symbol it's blocked in) is a timer
|
/// Whether `pid`'s `wchan` (the kernel symbol it's blocked in) is a timer
|
||||||
@@ -94,19 +159,51 @@ fn fd_points_at_tty(pid: i32, tty: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
/// `pid (comm) state ppid pgrp session tty_nr tpgid …`
|
||||||
fn parse_stat_state_handles_simple_and_tricky_comm() {
|
fn stat_line(comm: &str, state: char, tpgid: i32) -> String {
|
||||||
assert_eq!(parse_stat_state("1234 (bash) S 1 1234 ..."), Some('S'));
|
stat_line_pgrp(comm, state, 1234, tpgid)
|
||||||
assert_eq!(parse_stat_state("1234 (sleep) S 1 ..."), Some('S'));
|
}
|
||||||
assert_eq!(parse_stat_state("99 (cat) R 1 ..."), Some('R'));
|
|
||||||
// comm with spaces and a paren — only the final ')' delimits it.
|
fn stat_line_pgrp(comm: &str, state: char, pgrp: i32, tpgid: i32) -> String {
|
||||||
assert_eq!(parse_stat_state("42 (weird (name)) D 1 42 ..."), Some('D'));
|
format!("1234 ({comm}) {state} 1000 {pgrp} 1234 34816 {tpgid} 4194304 …")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_stat_state_rejects_garbage() {
|
fn parse_stat_reads_state_pgrp_and_tpgid() {
|
||||||
assert_eq!(parse_stat_state("not a stat line"), None);
|
let s = parse_stat(&stat_line_pgrp("bash", 'S', 500, 4321)).unwrap();
|
||||||
assert_eq!(parse_stat_state("123 (comm)"), None);
|
assert_eq!(s.state, 'S');
|
||||||
|
assert_eq!(s.pgrp, 500);
|
||||||
|
assert_eq!(s.tpgid, 4321);
|
||||||
|
|
||||||
|
let r = parse_stat(&stat_line_pgrp("cat", 'R', 7, 99)).unwrap();
|
||||||
|
assert_eq!(r.state, 'R');
|
||||||
|
assert_eq!(r.pgrp, 7);
|
||||||
|
assert_eq!(r.tpgid, 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `comm` is attacker-adjacent: it is the process name, it can contain
|
||||||
|
/// spaces and parens, and splitting the whole line on whitespace would put
|
||||||
|
/// every later field at the wrong index.
|
||||||
|
#[test]
|
||||||
|
fn parse_stat_indexes_from_the_final_paren() {
|
||||||
|
let s = parse_stat(&stat_line_pgrp("weird (name) with spaces", 'D', 42, 777)).unwrap();
|
||||||
|
assert_eq!(s.state, 'D');
|
||||||
|
assert_eq!(s.pgrp, 42);
|
||||||
|
assert_eq!(s.tpgid, 777);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_stat_carries_the_no_terminal_sentinel() {
|
||||||
|
// -1 means no controlling terminal; `foreground_pid` rejects it.
|
||||||
|
assert_eq!(parse_stat(&stat_line("daemon", 'S', -1)).unwrap().tpgid, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_stat_rejects_garbage() {
|
||||||
|
assert!(parse_stat("not a stat line").is_none());
|
||||||
|
assert!(parse_stat("123 (comm)").is_none());
|
||||||
|
// Truncated before tpgid.
|
||||||
|
assert!(parse_stat("123 (comm) S 1 1 1").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -121,9 +218,46 @@ mod tests {
|
|||||||
assert!(!is_timer_wchan("0"));
|
assert!(!is_timer_wchan("0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The snapshot indexes by process group, not by parentage — that is the
|
||||||
|
/// whole point, since a group member need not be under the pane. Checked
|
||||||
|
/// against this process's own group, which certainly has a live member.
|
||||||
|
#[test]
|
||||||
|
fn snapshot_indexes_this_process_under_its_group() {
|
||||||
|
// SAFETY: argument-less query of the caller's own process group.
|
||||||
|
let own = unsafe { libc::getpgid(0) };
|
||||||
|
assert!(own > 0, "expected a real process group, got {own}");
|
||||||
|
|
||||||
|
let groups = ProcGroups::snapshot();
|
||||||
|
let me = std::process::id() as i32;
|
||||||
|
assert!(
|
||||||
|
groups.members(own).contains(&me),
|
||||||
|
"snapshot missed this process ({me}) in group {own}: {:?}",
|
||||||
|
groups.members(own)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
groups.state(me).is_some(),
|
||||||
|
"a process in the snapshot must carry its state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_has_no_members_for_a_group_that_cannot_exist() {
|
||||||
|
assert!(ProcGroups::snapshot().members(i32::MAX).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pid_with_no_controlling_terminal_has_no_foreground_group() {
|
||||||
|
let groups = ProcGroups::snapshot();
|
||||||
|
assert_eq!(groups.foreground_pgid(i32::MAX), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn awaiting_input_is_false_for_nonexistent_pid() {
|
fn awaiting_input_is_false_for_nonexistent_pid() {
|
||||||
// A pid that can't exist → every read fails → false (fall back to Idle).
|
// A pid that can't exist → not in the snapshot → false (falls back to Idle).
|
||||||
assert!(!awaiting_input(i32::MAX, "/dev/pts/0"));
|
assert!(!awaiting_input(
|
||||||
|
i32::MAX,
|
||||||
|
"/dev/pts/0",
|
||||||
|
&ProcGroups::snapshot()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ use tokio::task::JoinHandle;
|
|||||||
|
|
||||||
/// How many recent PTY frames each new subscriber inherits before starting to
|
/// How many recent PTY frames each new subscriber inherits before starting to
|
||||||
/// receive live frames. A small buffer is fine — clients render a fresh
|
/// receive live frames. A small buffer is fine — clients render a fresh
|
||||||
/// terminal anyway. Sourced from the shared [`maestro_config::BroadcastTuning`]
|
/// terminal anyway. Sourced from [`maestro_config::PTY_CAPACITY`]
|
||||||
/// (NAP-273).
|
/// (NAP-273).
|
||||||
pub const PTY_BROADCAST_CAPACITY: usize = maestro_config::BroadcastTuning::DEFAULT.pty_capacity;
|
pub const PTY_BROADCAST_CAPACITY: usize = maestro_config::PTY_CAPACITY;
|
||||||
|
|
||||||
/// Default replay-buffer size — roughly one screenful for a typical 24×80
|
/// Default replay-buffer size — roughly one screenful for a typical 24×80
|
||||||
/// terminal plus reasonable scrollback. Override via
|
/// terminal plus reasonable scrollback. Override via
|
||||||
@@ -58,7 +58,9 @@ const READ_BUF_BYTES: usize = 4096;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PtyBridge {
|
pub struct PtyBridge {
|
||||||
fifo_path: PathBuf,
|
fifo_path: PathBuf,
|
||||||
sender: broadcast::Sender<Vec<u8>>,
|
/// `None` once [`Self::seal`] has run: live subscribers see the channel
|
||||||
|
/// close, but the replay buffer stays readable.
|
||||||
|
sender: Option<broadcast::Sender<Vec<u8>>>,
|
||||||
replay: ReplayBuffer,
|
replay: ReplayBuffer,
|
||||||
/// Monotonic timestamp of the last non-empty read off the FIFO, bumped by
|
/// Monotonic timestamp of the last non-empty read off the FIFO, bumped by
|
||||||
/// `run_reader`. The activity watcher reads it to gauge PTY quiescence
|
/// `run_reader`. The activity watcher reads it to gauge PTY quiescence
|
||||||
@@ -100,7 +102,7 @@ impl PtyBridge {
|
|||||||
});
|
});
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
fifo_path,
|
fifo_path,
|
||||||
sender,
|
sender: Some(sender),
|
||||||
replay,
|
replay,
|
||||||
last_activity,
|
last_activity,
|
||||||
task,
|
task,
|
||||||
@@ -123,7 +125,14 @@ impl PtyBridge {
|
|||||||
/// Subscribe to live PTY frames. Use [`Self::subscribe_with_replay`] for
|
/// Subscribe to live PTY frames. Use [`Self::subscribe_with_replay`] for
|
||||||
/// new attaches that want the buffered scrollback first.
|
/// new attaches that want the buffered scrollback first.
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
|
pub fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
|
||||||
self.sender.subscribe()
|
match &self.sender {
|
||||||
|
Some(tx) => tx.subscribe(),
|
||||||
|
None => {
|
||||||
|
let (tx, rx) = broadcast::channel(1);
|
||||||
|
drop(tx);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically snapshot the replay buffer and return a fresh subscriber
|
/// Atomically snapshot the replay buffer and return a fresh subscriber
|
||||||
@@ -134,10 +143,30 @@ impl PtyBridge {
|
|||||||
pub fn subscribe_with_replay(&self) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>) {
|
pub fn subscribe_with_replay(&self) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>) {
|
||||||
let guard = self.replay.lock_for_snapshot();
|
let guard = self.replay.lock_for_snapshot();
|
||||||
let snapshot = guard.snapshot();
|
let snapshot = guard.snapshot();
|
||||||
let rx = self.sender.subscribe();
|
let rx = match &self.sender {
|
||||||
|
Some(tx) => tx.subscribe(),
|
||||||
|
// Sealed: hand back the scrollback plus a receiver that is already
|
||||||
|
// closed, so the client renders the final output and then sees the
|
||||||
|
// stream end rather than hanging.
|
||||||
|
None => {
|
||||||
|
let (tx, rx) = broadcast::channel(1);
|
||||||
|
drop(tx);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
};
|
||||||
drop(guard);
|
drop(guard);
|
||||||
(snapshot, rx)
|
(snapshot, rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop reading and close the live stream, keeping the replay buffer.
|
||||||
|
///
|
||||||
|
/// Attached clients see the channel close — without that a `maestro attach`
|
||||||
|
/// sits in raw mode against a shell that has exited (NAP-226) — while the
|
||||||
|
/// buffered output survives for anyone who attaches later.
|
||||||
|
pub fn seal(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
self.sender = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for PtyBridge {
|
impl Drop for PtyBridge {
|
||||||
|
|||||||
@@ -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.
|
//! worktrees, and PTY bridges.
|
||||||
//!
|
//!
|
||||||
//! Owns the daemon's authoritative in-memory map of live sessions and is the
|
//! 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.
|
//! handler interact with tmux.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -12,46 +12,38 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, CreateSessionRequest, ErrorInfo, Session, SessionStatus, WorktreeSpec,
|
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus,
|
||||||
|
WorktreeSpec,
|
||||||
};
|
};
|
||||||
use tokio::sync::{broadcast, Mutex};
|
use tokio::sync::{broadcast, Mutex};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::panes::Panes;
|
||||||
use crate::pty::{pipe_pane_command, PtyBridge};
|
use crate::pty::{pipe_pane_command, PtyBridge};
|
||||||
use crate::sidecar::{SidecarError, SidecarStore};
|
use crate::sidecar::{SidecarError, SidecarStore};
|
||||||
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
||||||
use crate::tmux::{Tmux, TmuxError};
|
use crate::tmux::TmuxError;
|
||||||
use crate::worktree::{WorktreeError, WorktreeManager};
|
use crate::worktree::{WorktreeError, WorktreeManager};
|
||||||
|
|
||||||
/// Capacity of the session-events broadcast channel. The registry client
|
/// Capacity of the session-events broadcast channel. A subscriber that falls
|
||||||
/// listens; if it lags by this much we just drop frames (the registry already
|
/// this far behind gets `RecvError::Lagged`, which `/api/events` forwards as
|
||||||
/// reconciles on `Hello` via the snapshot). Sourced from the shared
|
/// [`maestro_protocol::CatalogEvent::Lagged`] so that client re-fetches.
|
||||||
/// [`maestro_config::BroadcastTuning`] so all broadcast channels track one
|
pub const EVENTS_CAPACITY: usize = maestro_config::EVENTS_CAPACITY;
|
||||||
/// definition (NAP-273).
|
|
||||||
pub const EVENTS_CAPACITY: usize = maestro_config::BroadcastTuning::DEFAULT.events_capacity;
|
|
||||||
|
|
||||||
/// How often the liveness watcher polls tmux for sessions that disappeared.
|
/// How often the liveness watcher polls tmux for sessions that disappeared.
|
||||||
pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum SessionEvent {
|
|
||||||
Added(Session),
|
|
||||||
Removed(Uuid),
|
|
||||||
Updated(Session),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SessionRecord {
|
pub struct SessionRecord {
|
||||||
pub session: Session,
|
pub session: Session,
|
||||||
pub fifo_path: PathBuf,
|
pub fifo_path: PathBuf,
|
||||||
/// Worktree we created at spawn time and that should be cleaned up on
|
/// Worktree we created at spawn time; removed on kill unless the
|
||||||
/// kill (if `cleanup_worktree_on_kill`).
|
/// dirty-work guard refuses (#56).
|
||||||
pub worktree_path: Option<PathBuf>,
|
pub worktree_path: Option<PathBuf>,
|
||||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||||
/// worktree remove` is invoked from the base repo.
|
/// worktree remove` is invoked from the base repo.
|
||||||
pub worktree_base_repo: Option<PathBuf>,
|
pub worktree_base_repo: Option<PathBuf>,
|
||||||
pub cleanup_worktree_on_kill: bool,
|
|
||||||
pub bridge: PtyBridge,
|
pub bridge: PtyBridge,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +59,8 @@ pub enum SessionError {
|
|||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
#[error("session not found: {0}")]
|
#[error("session not found: {0}")]
|
||||||
NotFound(Uuid),
|
NotFound(Uuid),
|
||||||
#[error("request targets daemon `{wanted}`, this daemon is `{ours}`")]
|
#[error("invalid environment variable name: {0:?}")]
|
||||||
WrongDaemon { wanted: String, ours: String },
|
InvalidEnv(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionError {
|
impl SessionError {
|
||||||
@@ -79,7 +71,7 @@ impl SessionError {
|
|||||||
SessionError::Sidecar(_) => "sidecar_failed",
|
SessionError::Sidecar(_) => "sidecar_failed",
|
||||||
SessionError::Io(_) => "io_failed",
|
SessionError::Io(_) => "io_failed",
|
||||||
SessionError::NotFound(_) => "not_found",
|
SessionError::NotFound(_) => "not_found",
|
||||||
SessionError::WrongDaemon { .. } => "wrong_daemon",
|
SessionError::InvalidEnv(_) => "invalid_env",
|
||||||
};
|
};
|
||||||
ErrorInfo {
|
ErrorInfo {
|
||||||
code: code.into(),
|
code: code.into(),
|
||||||
@@ -96,8 +88,7 @@ pub struct SessionManager {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct Inner {
|
struct Inner {
|
||||||
daemon_id: String,
|
tmux: Arc<dyn Panes>,
|
||||||
tmux: Tmux,
|
|
||||||
sidecar: SidecarStore,
|
sidecar: SidecarStore,
|
||||||
worktree_mgr: WorktreeManager,
|
worktree_mgr: WorktreeManager,
|
||||||
fifo_dir: PathBuf,
|
fifo_dir: PathBuf,
|
||||||
@@ -109,19 +100,17 @@ struct Inner {
|
|||||||
/// but disabled (`0`-day) history no-ops internally.
|
/// but disabled (`0`-day) history no-ops internally.
|
||||||
spawn_history: Option<SpawnHistory>,
|
spawn_history: Option<SpawnHistory>,
|
||||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||||
events: broadcast::Sender<SessionEvent>,
|
events: broadcast::Sender<CatalogEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionManager {
|
impl SessionManager {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
daemon_id: impl Into<String>,
|
tmux: Arc<dyn Panes>,
|
||||||
tmux: Tmux,
|
|
||||||
sidecar: SidecarStore,
|
sidecar: SidecarStore,
|
||||||
worktree_mgr: WorktreeManager,
|
worktree_mgr: WorktreeManager,
|
||||||
fifo_dir: PathBuf,
|
fifo_dir: PathBuf,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::new_with_replay(
|
Self::new_with_replay(
|
||||||
daemon_id,
|
|
||||||
tmux,
|
tmux,
|
||||||
sidecar,
|
sidecar,
|
||||||
worktree_mgr,
|
worktree_mgr,
|
||||||
@@ -133,8 +122,7 @@ impl SessionManager {
|
|||||||
/// Variant that lets the caller override the per-session replay buffer
|
/// Variant that lets the caller override the per-session replay buffer
|
||||||
/// cap. Pass `0` to disable replay (subscribers see only live frames).
|
/// cap. Pass `0` to disable replay (subscribers see only live frames).
|
||||||
pub fn new_with_replay(
|
pub fn new_with_replay(
|
||||||
daemon_id: impl Into<String>,
|
tmux: Arc<dyn Panes>,
|
||||||
tmux: Tmux,
|
|
||||||
sidecar: SidecarStore,
|
sidecar: SidecarStore,
|
||||||
worktree_mgr: WorktreeManager,
|
worktree_mgr: WorktreeManager,
|
||||||
fifo_dir: PathBuf,
|
fifo_dir: PathBuf,
|
||||||
@@ -143,7 +131,6 @@ impl SessionManager {
|
|||||||
let (events, _) = broadcast::channel(EVENTS_CAPACITY);
|
let (events, _) = broadcast::channel(EVENTS_CAPACITY);
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(Inner {
|
inner: Arc::new(Inner {
|
||||||
daemon_id: daemon_id.into(),
|
|
||||||
tmux,
|
tmux,
|
||||||
sidecar,
|
sidecar,
|
||||||
worktree_mgr,
|
worktree_mgr,
|
||||||
@@ -157,23 +144,21 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attach a durable spawn-history log (#24). Builder, called once right
|
/// Attach a durable spawn-history log (#24). Builder, called once right
|
||||||
/// after construction while the `Arc<Inner>` is uniquely owned — so
|
/// after construction while the `Arc<Inner>` is uniquely owned.
|
||||||
/// [`Arc::get_mut`] succeeds. A no-op (logged) on the off chance the handle
|
///
|
||||||
/// was already cloned.
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If the handle has already been cloned. That is a construction-order
|
||||||
|
/// mistake, and the alternative — logging and carrying on — produces a
|
||||||
|
/// daemon whose "recent dirs" picker is permanently empty with nothing to
|
||||||
|
/// show for it.
|
||||||
pub fn with_spawn_history(mut self, history: SpawnHistory) -> Self {
|
pub fn with_spawn_history(mut self, history: SpawnHistory) -> Self {
|
||||||
match Arc::get_mut(&mut self.inner) {
|
let inner = Arc::get_mut(&mut self.inner)
|
||||||
Some(inner) => inner.spawn_history = Some(history),
|
.expect("with_spawn_history must be called before the SessionManager is cloned");
|
||||||
None => tracing::error!(
|
inner.spawn_history = Some(history);
|
||||||
"with_spawn_history called after the SessionManager was cloned; ignoring"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn daemon_id(&self) -> &str {
|
|
||||||
&self.inner.daemon_id
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recent working dirs for the spawn form's picker (#24), newest-first and
|
/// 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.
|
/// deduped by dir. Empty when no history is wired or it's disabled.
|
||||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||||
@@ -184,7 +169,9 @@ impl SessionManager {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn events(&self) -> broadcast::Receiver<SessionEvent> {
|
/// Subscribe to session deltas. Each subscriber gets its own lag domain —
|
||||||
|
/// a slow client makes only itself resync.
|
||||||
|
pub fn events(&self) -> broadcast::Receiver<CatalogEvent> {
|
||||||
self.inner.events.subscribe()
|
self.inner.events.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,34 +203,29 @@ impl SessionManager {
|
|||||||
self.inner.sessions.lock().await.contains_key(&id)
|
self.inner.sessions.lock().await.contains_key(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current status of a session, or `None` if we do not know it.
|
||||||
|
pub async fn status(&self, id: Uuid) -> Option<SessionStatus> {
|
||||||
|
let sessions = self.inner.sessions.lock().await;
|
||||||
|
sessions.get(&id).map(|r| r.session.status)
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
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 {
|
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
|
||||||
Some(WorktreeSpec {
|
Some(spec) => {
|
||||||
base_repo, branch, ..
|
let WorktreeSpec {
|
||||||
}) => {
|
base_repo, branch, ..
|
||||||
let path = self
|
} = spec;
|
||||||
.inner
|
let path = if spec.create_branch {
|
||||||
.worktree_mgr
|
self.inner.worktree_mgr.add_new(base_repo, branch)
|
||||||
.add(base_repo, branch)
|
} else {
|
||||||
.map_err(SessionError::Worktree)?;
|
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
||||||
|
}
|
||||||
|
.map_err(SessionError::Worktree)?;
|
||||||
(path.clone(), Some(path), Some(base_repo.clone()))
|
(path.clone(), Some(path), Some(base_repo.clone()))
|
||||||
}
|
}
|
||||||
None => (req.cwd.clone(), None, None),
|
None => (req.cwd.clone(), None, None),
|
||||||
};
|
};
|
||||||
let cleanup_worktree_on_kill = req
|
|
||||||
.worktree
|
|
||||||
.as_ref()
|
|
||||||
.map(|w| w.cleanup_on_kill)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
// Build the FIFO + reader task *before* asking tmux to spawn the
|
// Build the FIFO + reader task *before* asking tmux to spawn the
|
||||||
// command — otherwise the child's first bytes race the separate
|
// command — otherwise the child's first bytes race the separate
|
||||||
@@ -262,12 +244,21 @@ impl SessionManager {
|
|||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let session_env = match sanitize_env(&req.env) {
|
||||||
|
Ok(env) => env,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_file(&fifo_path);
|
||||||
|
self.rollback_worktree(&worktree_base_repo, &worktree_path);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
let spawned = {
|
let spawned = {
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let cwd = effective_cwd.clone();
|
let cwd = effective_cwd.clone();
|
||||||
let command = req.command.clone();
|
let command = req.command.clone();
|
||||||
let sink = pipe_pane_command(&fifo_path);
|
let sink = pipe_pane_command(&fifo_path);
|
||||||
tmux_blocking(move || tmux.new_session_piped(id, &cwd, &command, &sink)).await
|
let env = session_env.clone();
|
||||||
|
tmux_blocking(move || tmux.new_session_piped(id, &cwd, &command, &env, &sink)).await
|
||||||
};
|
};
|
||||||
if let Err(e) = spawned {
|
if let Err(e) = spawned {
|
||||||
// `bridge` drops at the end of this block, closing the FIFO reader.
|
// `bridge` drops at the end of this block, closing the FIFO reader.
|
||||||
@@ -285,7 +276,6 @@ impl SessionManager {
|
|||||||
|
|
||||||
let session = Session {
|
let session = Session {
|
||||||
id,
|
id,
|
||||||
daemon_id: self.inner.daemon_id.clone(),
|
|
||||||
cwd: effective_cwd,
|
cwd: effective_cwd,
|
||||||
command: req.command,
|
command: req.command,
|
||||||
env: req.env,
|
env: req.env,
|
||||||
@@ -320,12 +310,13 @@ impl SessionManager {
|
|||||||
fifo_path,
|
fifo_path,
|
||||||
worktree_path,
|
worktree_path,
|
||||||
worktree_base_repo,
|
worktree_base_repo,
|
||||||
cleanup_worktree_on_kill,
|
|
||||||
bridge,
|
bridge,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let _ = self.inner.events.send(SessionEvent::Added(session.clone()));
|
let _ = self.inner.events.send(CatalogEvent::SessionAdded {
|
||||||
|
session: session.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
// Best-effort: log the spawn for the recents picker (#24). A log-write
|
// Best-effort: log the spawn for the recents picker (#24). A log-write
|
||||||
// failure must never fail an otherwise-successful spawn, so the error
|
// failure must never fail an otherwise-successful spawn, so the error
|
||||||
@@ -360,15 +351,21 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kill a session and remove its on-disk artifacts. The worktree is only
|
/// Kill a session and remove its on-disk artifacts. The worktree is
|
||||||
/// pruned if the create request set `cleanup_on_kill: true`.
|
/// always pruned -- unless it holds uncommitted changes or commits not on
|
||||||
|
/// any other ref, in which case the guard leaves it on disk (#56).
|
||||||
|
///
|
||||||
|
/// This is also how an already-dead session is dismissed. tmux is only
|
||||||
|
/// asked to kill something it still has — calling `kill-session` on a
|
||||||
|
/// session tmux already lost fails, and that failure is not interesting.
|
||||||
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
|
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
|
||||||
let removed = {
|
let removed = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
sessions.remove(&id)
|
sessions.remove(&id)
|
||||||
};
|
};
|
||||||
let record = removed.ok_or(SessionError::NotFound(id))?;
|
let record = removed.ok_or(SessionError::NotFound(id))?;
|
||||||
cleanup_record(&self.inner, id, record, true).await;
|
let alive = record.session.status == SessionStatus::Running;
|
||||||
|
cleanup_record(&self.inner, id, record, alive).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,11 +412,10 @@ impl SessionManager {
|
|||||||
};
|
};
|
||||||
let mut adopted = 0usize;
|
let mut adopted = 0usize;
|
||||||
for id in live {
|
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,
|
Ok(Some(s)) => s,
|
||||||
_ => Session {
|
_ => Session {
|
||||||
id,
|
id,
|
||||||
daemon_id: self.inner.daemon_id.clone(),
|
|
||||||
cwd: PathBuf::from("/"),
|
cwd: PathBuf::from("/"),
|
||||||
command: String::new(),
|
command: String::new(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
@@ -434,19 +430,6 @@ impl SessionManager {
|
|||||||
current_cwd: None,
|
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 fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||||
let bridge =
|
let bridge =
|
||||||
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
|
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
|
||||||
@@ -466,19 +449,26 @@ impl SessionManager {
|
|||||||
fifo_path,
|
fifo_path,
|
||||||
worktree_path: None,
|
worktree_path: None,
|
||||||
worktree_base_repo: None,
|
worktree_base_repo: None,
|
||||||
cleanup_worktree_on_kill: false,
|
|
||||||
bridge,
|
bridge,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let _ = self.inner.events.send(SessionEvent::Added(session));
|
let _ = self
|
||||||
|
.inner
|
||||||
|
.events
|
||||||
|
.send(CatalogEvent::SessionAdded { session });
|
||||||
adopted += 1;
|
adopted += 1;
|
||||||
}
|
}
|
||||||
Ok(adopted)
|
Ok(adopted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forward keystrokes to a session's pane. A dead session is read-only —
|
||||||
|
/// its pane is gone, so this is a no-op rather than a tmux failure the
|
||||||
|
/// client would have to interpret.
|
||||||
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
||||||
if !self.has(id).await {
|
match self.status(id).await {
|
||||||
return Err(SessionError::NotFound(id));
|
None => return Err(SessionError::NotFound(id)),
|
||||||
|
Some(SessionStatus::Dead) => return Ok(()),
|
||||||
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
let bytes = bytes.to_vec();
|
let bytes = bytes.to_vec();
|
||||||
@@ -487,9 +477,13 @@ impl SessionManager {
|
|||||||
.map_err(SessionError::Tmux)
|
.map_err(SessionError::Tmux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resize a session's window. No-op for a dead session, as with
|
||||||
|
/// [`Self::send_keys`].
|
||||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||||
if !self.has(id).await {
|
match self.status(id).await {
|
||||||
return Err(SessionError::NotFound(id));
|
None => return Err(SessionError::NotFound(id)),
|
||||||
|
Some(SessionStatus::Dead) => return Ok(()),
|
||||||
|
Some(SessionStatus::Running) => {}
|
||||||
}
|
}
|
||||||
let tmux = self.inner.tmux.clone();
|
let tmux = self.inner.tmux.clone();
|
||||||
tmux_blocking(move || tmux.resize_window(id, cols, rows))
|
tmux_blocking(move || tmux.resize_window(id, cols, rows))
|
||||||
@@ -501,8 +495,26 @@ impl SessionManager {
|
|||||||
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it
|
/// 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
|
/// 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
|
/// 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).
|
/// liveness watcher, and PTY readers (NAP-256).
|
||||||
|
/// Turn a request's environment into tmux `-e KEY=VAL` material.
|
||||||
|
///
|
||||||
|
/// A key containing `=` or NUL would be parsed by tmux as a different
|
||||||
|
/// assignment than the caller wrote, so it is rejected rather than silently
|
||||||
|
/// reinterpreted. `BTreeMap` so the resulting argv is deterministic.
|
||||||
|
fn sanitize_env(
|
||||||
|
env: &std::collections::HashMap<String, String>,
|
||||||
|
) -> Result<std::collections::BTreeMap<String, String>, SessionError> {
|
||||||
|
let mut out = std::collections::BTreeMap::new();
|
||||||
|
for (k, v) in env {
|
||||||
|
if k.is_empty() || k.contains('=') || k.contains('\0') || v.contains('\0') {
|
||||||
|
return Err(SessionError::InvalidEnv(k.clone()));
|
||||||
|
}
|
||||||
|
out.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
async fn tmux_blocking<T, F>(f: F) -> T
|
async fn tmux_blocking<T, F>(f: F) -> T
|
||||||
where
|
where
|
||||||
F: FnOnce() -> T + Send + 'static,
|
F: FnOnce() -> T + Send + 'static,
|
||||||
@@ -520,13 +532,22 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
|
|||||||
// nothing to reap and no reason to spawn a tmux subprocess (this also
|
// nothing to reap and no reason to spawn a tmux subprocess (this also
|
||||||
// avoids racing with the very first `new-session` that would otherwise
|
// avoids racing with the very first `new-session` that would otherwise
|
||||||
// be competing to start the tmux server).
|
// be competing to start the tmux server).
|
||||||
|
// Only sessions we still believe are running can transition; a session
|
||||||
|
// already marked Dead must not re-emit an update on every 2s tick.
|
||||||
let known: Vec<Uuid> = {
|
let known: Vec<Uuid> = {
|
||||||
let sessions = inner.sessions.lock().await;
|
let sessions = inner.sessions.lock().await;
|
||||||
if sessions.is_empty() {
|
if sessions.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessions.keys().copied().collect()
|
sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, r)| r.session.status == SessionStatus::Running)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect()
|
||||||
};
|
};
|
||||||
|
if known.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let live_ids = {
|
let live_ids = {
|
||||||
let tmux = inner.tmux.clone();
|
let tmux = inner.tmux.clone();
|
||||||
@@ -542,7 +563,7 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
|
|||||||
|
|
||||||
for id in known {
|
for id in known {
|
||||||
if !live.contains(&id) {
|
if !live.contains(&id) {
|
||||||
reap_dead_session(inner, id).await;
|
mark_session_dead(inner, id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,8 +577,22 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
|||||||
if sessions.is_empty() {
|
if sessions.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessions.keys().copied().collect()
|
// A dead session has no pane to inspect; its last observed activity is
|
||||||
|
// kept as-is so the UI can still show what it was doing when it died.
|
||||||
|
sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, r)| r.session.status == SessionStatus::Running)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect()
|
||||||
};
|
};
|
||||||
|
if ids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One `/proc` walk for the whole pass, rather than one per session. Reading
|
||||||
|
// it is pure filesystem work, so it goes on the blocking pool alongside the
|
||||||
|
// tmux calls below.
|
||||||
|
let groups = tmux_blocking(crate::proc::ProcGroups::snapshot).await;
|
||||||
|
|
||||||
for id in ids {
|
for id in ids {
|
||||||
// The tmux pane lookup is a subprocess — keep it off the runtime worker
|
// The tmux pane lookup is a subprocess — keep it off the runtime worker
|
||||||
@@ -575,7 +610,7 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
// `/proc` inspection is cheap, but still done off the async lock.
|
// `/proc` inspection is cheap, but still done off the async lock.
|
||||||
let awaiting = crate::proc::awaiting_input(pane.pane_pid, &pane.tty);
|
let awaiting = crate::proc::awaiting_input(pane.pane_pid, &pane.tty, &groups);
|
||||||
|
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
@@ -607,24 +642,41 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(session) = updated {
|
if let Some(session) = updated {
|
||||||
let _ = inner.events.send(SessionEvent::Updated(session));
|
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a session whose tmux session is already gone: drop the in-memory
|
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
|
||||||
/// record, delete the sidecar + FIFO, clean up its worktree if requested, and
|
/// and announce it, keeping everything else intact.
|
||||||
/// fire `SessionEvent::Removed`. No tmux kill — there is nothing left to kill.
|
///
|
||||||
async fn reap_dead_session(inner: &Arc<Inner>, id: Uuid) {
|
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
|
||||||
let record = {
|
/// still attach and read the replay buffer — the last thing a crashed agent
|
||||||
|
/// printed. Artifacts are only released when the operator dismisses the session
|
||||||
|
/// via [`SessionManager::kill`].
|
||||||
|
///
|
||||||
|
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
||||||
|
/// adopts only what tmux still holds.
|
||||||
|
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||||
|
let updated = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
sessions.remove(&id)
|
match sessions.get_mut(&id) {
|
||||||
|
Some(record) if record.session.status == SessionStatus::Running => {
|
||||||
|
record.session.status = SessionStatus::Dead;
|
||||||
|
// Release anyone attached; the scrollback survives for a later
|
||||||
|
// attach.
|
||||||
|
record.bridge.seal();
|
||||||
|
Some(record.session.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let Some(record) = record else {
|
let Some(session) = updated else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
tracing::info!(%id, "session vanished from tmux — reaping");
|
tracing::info!(%id, "session vanished from tmux — marking dead");
|
||||||
cleanup_record(inner, id, record, false).await;
|
let _ = inner.sidecar.write(&session);
|
||||||
|
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tear down a session's artifacts after it has already been removed from the
|
/// Tear down a session's artifacts after it has already been removed from the
|
||||||
@@ -645,10 +697,19 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
|
|||||||
if let Err(e) = inner.sidecar.delete(id) {
|
if let Err(e) = inner.sidecar.delete(id) {
|
||||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||||
}
|
}
|
||||||
if record.cleanup_worktree_on_kill {
|
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
|
||||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
// user-facing choice. The manager's guard is what protects unsaved work --
|
||||||
let _ = inner.worktree_mgr.remove(base, path);
|
// when it refuses, leave the tree on disk and say where it is so the
|
||||||
|
// operator can find it.
|
||||||
|
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||||
|
if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||||
|
tracing::warn!(
|
||||||
|
%id,
|
||||||
|
path = %path.display(),
|
||||||
|
error = %e,
|
||||||
|
"left worktree on disk"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = inner.events.send(SessionEvent::Removed(id));
|
let _ = inner.events.send(CatalogEvent::SessionRemoved { id });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! All sessions managed through this wrapper are named `maestro-<uuid>` so that
|
//! All sessions managed through this wrapper are named `maestro-<uuid>` so that
|
||||||
//! the daemon can recover its own sessions on restart (and ignore foreign ones).
|
//! the daemon can recover its own sessions on restart (and ignore foreign ones).
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::os::unix::ffi::OsStrExt;
|
use std::os::unix::ffi::OsStrExt;
|
||||||
@@ -75,6 +76,10 @@ impl Tmux {
|
|||||||
if let Some(d) = &self.tmpdir {
|
if let Some(d) = &self.tmpdir {
|
||||||
c.env("TMUX_TMPDIR", d);
|
c.env("TMUX_TMPDIR", d);
|
||||||
}
|
}
|
||||||
|
// A daemon started from inside a tmux pane inherits `TMUX`, and tmux
|
||||||
|
// resolves that ahead of `TMUX_TMPDIR` — so without this the daemon
|
||||||
|
// drives the developer's own server and `tmux_tmpdir` does nothing.
|
||||||
|
c.env_remove("TMUX");
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +101,13 @@ impl Tmux {
|
|||||||
let command = login_shell_command(env::var_os("SHELL").as_deref(), command);
|
let command = login_shell_command(env::var_os("SHELL").as_deref(), command);
|
||||||
let out = self
|
let out = self
|
||||||
.cmd()
|
.cmd()
|
||||||
.args(new_session_args(&name, cwd, &command, None))
|
.args(new_session_args(
|
||||||
|
&name,
|
||||||
|
cwd,
|
||||||
|
&command,
|
||||||
|
&BTreeMap::new(),
|
||||||
|
None,
|
||||||
|
))
|
||||||
.output()?;
|
.output()?;
|
||||||
check(out)
|
check(out)
|
||||||
}
|
}
|
||||||
@@ -112,13 +123,20 @@ impl Tmux {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
command: &str,
|
command: &str,
|
||||||
|
session_env: &BTreeMap<String, String>,
|
||||||
sink_command: &str,
|
sink_command: &str,
|
||||||
) -> Result<(), TmuxError> {
|
) -> Result<(), TmuxError> {
|
||||||
let name = session_name(id);
|
let name = session_name(id);
|
||||||
let command = login_shell_command(env::var_os("SHELL").as_deref(), command);
|
let command = login_shell_command(env::var_os("SHELL").as_deref(), command);
|
||||||
let out = self
|
let out = self
|
||||||
.cmd()
|
.cmd()
|
||||||
.args(new_session_args(&name, cwd, &command, Some(sink_command)))
|
.args(new_session_args(
|
||||||
|
&name,
|
||||||
|
cwd,
|
||||||
|
&command,
|
||||||
|
session_env,
|
||||||
|
Some(sink_command),
|
||||||
|
))
|
||||||
.output()?;
|
.output()?;
|
||||||
check(out)
|
check(out)
|
||||||
}
|
}
|
||||||
@@ -263,12 +281,22 @@ impl Tmux {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phrasings tmux uses for "there is no server on this socket". They vary by
|
||||||
|
/// version, and `list_sessions` turning one of these into an `Err` stops the
|
||||||
|
/// liveness watcher reaping for that pass — so a missing phrasing is silent.
|
||||||
|
const NO_SERVER_PHRASES: &[&str] = &[
|
||||||
|
"no server running",
|
||||||
|
"no sessions",
|
||||||
|
"error connecting",
|
||||||
|
// tmux >= 3.7 when the server shuts down while this client is connected,
|
||||||
|
// which is exactly what killing the last session does.
|
||||||
|
"server exited unexpectedly",
|
||||||
|
];
|
||||||
|
|
||||||
/// True if tmux's stderr indicates "there is no tmux server running on this
|
/// True if tmux's stderr indicates "there is no tmux server running on this
|
||||||
/// socket" (multiple phrasings across versions).
|
/// socket" (multiple phrasings across versions).
|
||||||
fn is_no_server_error(stderr: &str) -> bool {
|
fn is_no_server_error(stderr: &str) -> bool {
|
||||||
stderr.contains("no server running")
|
NO_SERVER_PHRASES.iter().any(|p| stderr.contains(p))
|
||||||
|| stderr.contains("no sessions")
|
|
||||||
|| stderr.contains("error connecting")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A session pane's process id, foreground command, controlling tty, and
|
/// A session pane's process id, foreground command, controlling tty, and
|
||||||
@@ -360,6 +388,7 @@ fn new_session_args(
|
|||||||
name: &str,
|
name: &str,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
command: &str,
|
command: &str,
|
||||||
|
env: &BTreeMap<String, String>,
|
||||||
pipe_sink: Option<&str>,
|
pipe_sink: Option<&str>,
|
||||||
) -> Vec<OsString> {
|
) -> Vec<OsString> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
@@ -378,8 +407,14 @@ fn new_session_args(
|
|||||||
OsString::from(name),
|
OsString::from(name),
|
||||||
OsString::from("-c"),
|
OsString::from("-c"),
|
||||||
cwd.as_os_str().to_os_string(),
|
cwd.as_os_str().to_os_string(),
|
||||||
OsString::from(command),
|
|
||||||
];
|
];
|
||||||
|
// Caller-supplied environment. Sorted so the argv is deterministic, and
|
||||||
|
// therefore assertable.
|
||||||
|
for (k, v) in env {
|
||||||
|
args.push(OsString::from("-e"));
|
||||||
|
args.push(OsString::from(format!("{k}={v}")));
|
||||||
|
}
|
||||||
|
args.push(OsString::from(command));
|
||||||
if let Some(sink) = pipe_sink {
|
if let Some(sink) = pipe_sink {
|
||||||
args.extend([
|
args.extend([
|
||||||
OsString::from(";"),
|
OsString::from(";"),
|
||||||
@@ -407,6 +442,82 @@ fn check(out: Output) -> Result<(), TmuxError> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Every phrasing must be recognised: an unmatched one makes
|
||||||
|
/// `list_sessions` fail, and a failed pass reaps nothing.
|
||||||
|
#[test]
|
||||||
|
fn new_session_args_emit_sorted_env_flags_before_the_command() {
|
||||||
|
let env: BTreeMap<String, String> = [
|
||||||
|
("ZED".to_string(), "9".to_string()),
|
||||||
|
("ALPHA".to_string(), "1".to_string()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let args = new_session_args("maestro-x", Path::new("/tmp"), "bash", &env, None);
|
||||||
|
let flat: Vec<String> = args
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let alpha = flat.iter().position(|a| a == "ALPHA=1").expect("ALPHA");
|
||||||
|
let zed = flat.iter().position(|a| a == "ZED=9").expect("ZED");
|
||||||
|
let command = flat.iter().rposition(|a| a == "bash").expect("command");
|
||||||
|
assert!(alpha < zed, "env flags must be sorted for a stable argv");
|
||||||
|
assert!(zed < command, "env must precede the command argument");
|
||||||
|
assert_eq!(flat[alpha - 1], "-e");
|
||||||
|
assert_eq!(flat[zed - 1], "-e");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TMUX` beats `TMUX_TMPDIR` when tmux looks for a server, so leaving an
|
||||||
|
/// inherited one in place would silently ignore the configured tmpdir.
|
||||||
|
#[test]
|
||||||
|
fn cmd_clears_an_inherited_tmux_and_sets_the_configured_tmpdir() {
|
||||||
|
let tmux = Tmux::new().with_tmpdir("/run/maestro-dev");
|
||||||
|
let cmd = tmux.cmd();
|
||||||
|
let envs: Vec<_> = cmd.get_envs().collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
envs.contains(&(OsStr::new("TMUX"), None)),
|
||||||
|
"TMUX must be removed, got {envs:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
envs.contains(&(
|
||||||
|
OsStr::new("TMUX_TMPDIR"),
|
||||||
|
Some(OsStr::new("/run/maestro-dev"))
|
||||||
|
)),
|
||||||
|
"TMUX_TMPDIR must be set, got {envs:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The removal is unconditional: a daemon manages its own server whether or
|
||||||
|
/// not a tmpdir was configured.
|
||||||
|
#[test]
|
||||||
|
fn cmd_clears_an_inherited_tmux_without_a_configured_tmpdir() {
|
||||||
|
let cmd = Tmux::new().cmd();
|
||||||
|
let envs: Vec<_> = cmd.get_envs().collect();
|
||||||
|
assert!(
|
||||||
|
envs.contains(&(OsStr::new("TMUX"), None)),
|
||||||
|
"TMUX must be removed, got {envs:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_no_server_error_matches_every_known_phrasing() {
|
||||||
|
for phrase in NO_SERVER_PHRASES {
|
||||||
|
assert!(is_no_server_error(phrase), "unmatched: {phrase}");
|
||||||
|
assert!(
|
||||||
|
is_no_server_error(&format!("{phrase}\n")),
|
||||||
|
"unmatched with trailing newline: {phrase}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_no_server_error_rejects_unrelated_failures() {
|
||||||
|
assert!(!is_no_server_error("can't find session: maestro-x"));
|
||||||
|
assert!(!is_no_server_error("usage: kill-session"));
|
||||||
|
assert!(!is_no_server_error(""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_pane_line_splits_pid_command_tty_path() {
|
fn parse_pane_line_splits_pid_command_tty_path() {
|
||||||
let p = parse_pane_line("12345 claude /dev/pts/3 /home/naps62/maestro").unwrap();
|
let p = parse_pane_line("12345 claude /dev/pts/3 /home/naps62/maestro").unwrap();
|
||||||
@@ -440,7 +551,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_session_args_pass_256color_env_and_default_terminal() {
|
fn new_session_args_pass_256color_env_and_default_terminal() {
|
||||||
let args = new_session_args("maestro-abc", Path::new("/tmp/proj"), "sleep 30", None);
|
let args = new_session_args(
|
||||||
|
"maestro-abc",
|
||||||
|
Path::new("/tmp/proj"),
|
||||||
|
"sleep 30",
|
||||||
|
&BTreeMap::new(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
let strs: Vec<&str> = args.iter().map(|s| s.to_str().unwrap()).collect();
|
let strs: Vec<&str> = args.iter().map(|s| s.to_str().unwrap()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strs,
|
strs,
|
||||||
@@ -496,6 +613,7 @@ mod tests {
|
|||||||
"maestro-abc",
|
"maestro-abc",
|
||||||
Path::new("/tmp/proj"),
|
Path::new("/tmp/proj"),
|
||||||
"sleep 30",
|
"sleep 30",
|
||||||
|
&BTreeMap::new(),
|
||||||
Some("cat >> '/tmp/fifo'"),
|
Some("cat >> '/tmp/fifo'"),
|
||||||
);
|
);
|
||||||
let strs: Vec<&str> = args.iter().map(|s| s.to_str().unwrap()).collect();
|
let strs: Vec<&str> = args.iter().map(|s| s.to_str().unwrap()).collect();
|
||||||
|
|||||||
@@ -4,64 +4,113 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Output};
|
use std::process::{Command, Output};
|
||||||
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum WorktreeError {
|
pub enum WorktreeError {
|
||||||
#[error("failed to spawn git: {0}")]
|
#[error("failed to spawn git: {0}")]
|
||||||
Spawn(#[from] std::io::Error),
|
Spawn(#[from] std::io::Error),
|
||||||
#[error("git exited with code {code}: {stderr}")]
|
#[error("git exited with code {code}: {stderr}")]
|
||||||
NonZero { code: i32, stderr: String },
|
NonZero { code: i32, stderr: String },
|
||||||
|
/// A worktree already occupies the target path. The caller decides what
|
||||||
|
/// to do; the daemon never relocates to a different directory (#56).
|
||||||
|
#[error("worktree path already exists: {}", .path.display())]
|
||||||
|
PathExists { path: PathBuf },
|
||||||
|
/// The worktree holds unsaved work; removal was refused (#56).
|
||||||
|
#[error("left {} on disk: {}", .path.display(), .reason)]
|
||||||
|
Guarded { path: PathBuf, reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a unique path under `root` and runs `git worktree add` rooted at
|
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
|
||||||
/// `base_repo`. Each call appends a UUID-suffixed directory so concurrent
|
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
|
||||||
/// `add()`s never collide.
|
/// are findable from the repo; an operator-set root overrides that.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WorktreeManager {
|
pub struct WorktreeManager {
|
||||||
root: PathBuf,
|
/// `[daemon].worktree_root` override. `None` uses the default
|
||||||
|
/// `<repo>/worktrees/<branch>` placement.
|
||||||
|
root_override: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorktreeManager {
|
impl WorktreeManager {
|
||||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
pub fn new(root_override: Option<PathBuf>) -> Self {
|
||||||
Self { root: root.into() }
|
Self { root_override }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn root(&self) -> &Path {
|
pub fn root(&self) -> Option<&Path> {
|
||||||
&self.root
|
self.root_override.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `git -C <base_repo> worktree add -b <branch> <generated-path>`.
|
/// Where a worktree for `branch` will be created: the override root, or
|
||||||
/// Returns the path of the new worktree on success.
|
/// `<base_repo>/worktrees/<sanitized branch>` by default. The path is
|
||||||
pub fn add(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
/// predictable (no UUID suffix), so an existing directory at the target
|
||||||
std::fs::create_dir_all(&self.root)?;
|
/// is an error rather than a silently different location.
|
||||||
let path = self.generate_path(branch);
|
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
|
||||||
let out = Command::new("git")
|
let slug = sanitize_branch_for_path(branch);
|
||||||
.arg("-C")
|
match &self.root_override {
|
||||||
.arg(base_repo)
|
Some(root) => root.join(slug),
|
||||||
.args(["worktree", "add", "-b", branch])
|
None => base_repo.join("worktrees").join(slug),
|
||||||
.arg(&path)
|
}
|
||||||
.output()?;
|
}
|
||||||
|
|
||||||
|
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
|
||||||
|
/// branch. Returns the path of the new worktree on success.
|
||||||
|
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
||||||
|
self.run_add(base_repo, branch, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `git -C <base_repo> worktree add <path> <branch>` — checks out a branch
|
||||||
|
/// that must already exist (#56).
|
||||||
|
pub fn add_existing(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
||||||
|
self.run_add(base_repo, branch, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_add(
|
||||||
|
&self,
|
||||||
|
base_repo: &Path,
|
||||||
|
branch: &str,
|
||||||
|
create_branch: bool,
|
||||||
|
) -> Result<PathBuf, WorktreeError> {
|
||||||
|
let path = self.target_path(base_repo, branch);
|
||||||
|
if path.symlink_metadata().is_ok() {
|
||||||
|
return Err(WorktreeError::PathExists { path });
|
||||||
|
}
|
||||||
|
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||||
|
// yet; the target itself must not.
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
|
||||||
|
if create_branch {
|
||||||
|
cmd.arg("-b").arg(branch).arg(&path);
|
||||||
|
} else {
|
||||||
|
// Existing branch: `worktree add <path> <branch>` with no `-b`.
|
||||||
|
cmd.arg(&path).arg(branch);
|
||||||
|
}
|
||||||
|
let out = cmd.output()?;
|
||||||
check(out)?;
|
check(out)?;
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `git -C <base_repo> worktree remove --force <path>`.
|
/// `git -C <base_repo> worktree remove <path>` — but only when the
|
||||||
|
/// worktree holds nothing unique (#56). A worktree with uncommitted
|
||||||
|
/// changes, or with commits reachable from no other ref, is refused and
|
||||||
|
/// left on disk so the operator can rescue it; cleanup is unconditional,
|
||||||
|
/// which makes this guard the only thing standing between a stray kill
|
||||||
|
/// and destroyed work.
|
||||||
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
||||||
|
if let Some(reason) = unsafe_to_remove(path)? {
|
||||||
|
return Err(WorktreeError::Guarded {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
let out = Command::new("git")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(base_repo)
|
.arg(base_repo)
|
||||||
.args(["worktree", "remove", "--force"])
|
.args(["worktree", "remove"])
|
||||||
.arg(path)
|
.arg(path)
|
||||||
.output()?;
|
.output()?;
|
||||||
check(out)
|
check(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_path(&self, branch: &str) -> PathBuf {
|
|
||||||
let slug = sanitize_branch_for_path(branch);
|
|
||||||
let unique = Uuid::new_v4().simple();
|
|
||||||
self.root.join(format!("{slug}-{unique}"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_branch_for_path(branch: &str) -> String {
|
fn sanitize_branch_for_path(branch: &str) -> String {
|
||||||
@@ -79,6 +128,71 @@ fn sanitize_branch_for_path(branch: &str) -> String {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Some(reason)` when deleting `path` would destroy work: uncommitted
|
||||||
|
/// changes, or commits reachable from no ref other than the worktree's own
|
||||||
|
/// branch (`git worktree remove` keeps the branch, but nothing checks it out
|
||||||
|
/// anymore — treat its commits as at risk anyway, per #56).
|
||||||
|
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
|
||||||
|
let status = run_git(path, ["status", "--porcelain"])?;
|
||||||
|
if !status.stdout.is_empty() {
|
||||||
|
return Ok(Some("has uncommitted changes".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let head = String::from_utf8_lossy(&run_git(path, ["rev-parse", "HEAD"])?.stdout)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let branch =
|
||||||
|
String::from_utf8_lossy(&run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?.stdout)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Every ref except this worktree's own branch counts as a safe home for
|
||||||
|
// its commits. Detached HEAD excludes everything.
|
||||||
|
let refs =
|
||||||
|
String::from_utf8_lossy(&run_git(path, ["for-each-ref", "--format=%(refname)"])?.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter(|r| match branch.as_str() {
|
||||||
|
"HEAD" => true,
|
||||||
|
b => *r != format!("refs/heads/{b}"),
|
||||||
|
})
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.arg("-C").arg(path).args(["rev-list", "--count", &head]);
|
||||||
|
if !refs.is_empty() {
|
||||||
|
cmd.arg("--not").args(&refs);
|
||||||
|
}
|
||||||
|
let out = cmd.output()?;
|
||||||
|
check(out.clone())?;
|
||||||
|
let count: usize = String::from_utf8_lossy(&out.stdout)
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(0);
|
||||||
|
if count > 0 {
|
||||||
|
return Ok(Some(format!(
|
||||||
|
"has {count} commit(s) not present on any other ref"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git(
|
||||||
|
dir: &Path,
|
||||||
|
args: impl IntoIterator<Item = &'static str>,
|
||||||
|
) -> Result<Output, WorktreeError> {
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.arg("-C").arg(dir).args(args);
|
||||||
|
let out = cmd.output()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(WorktreeError::NonZero {
|
||||||
|
code: out.status.code().unwrap_or(-1),
|
||||||
|
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
fn check(out: Output) -> Result<(), WorktreeError> {
|
fn check(out: Output) -> Result<(), WorktreeError> {
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//! Activity detection against real processes on a real tty.
|
||||||
|
//!
|
||||||
|
//! The review noted this pipeline had no end-to-end coverage — `activity::decide`
|
||||||
|
//! was unit-tested, but nothing exercised the `/proc` inspection that feeds it.
|
||||||
|
//! These use a real tmux pane because that is the only way to get a controlling
|
||||||
|
//! terminal with a genuine foreground process group.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use common::TmuxFixture;
|
||||||
|
use maestro_daemon::proc::{awaiting_input, ProcGroups};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Poll `awaiting_input` until it reports `want`, so we don't race the shell
|
||||||
|
/// reaching its blocking read. Reports what it last saw — the useful half of
|
||||||
|
/// the failure.
|
||||||
|
fn wait_for_awaiting(fx: &TmuxFixture, id: Uuid, want: bool, label: &str) {
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut last = None;
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
if let Ok(Some(pane)) = fx.tmux.pane_info(id) {
|
||||||
|
// Fresh each poll: the tree is still settling.
|
||||||
|
let got = awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot());
|
||||||
|
if got == want {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
last = Some(got);
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
panic!("{label}: wanted awaiting_input={want}, last saw {last:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_idle_shell_is_awaiting_input() {
|
||||||
|
let fx = TmuxFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
fx.tmux
|
||||||
|
.new_session(id, cwd.path(), "bash --noprofile --norc")
|
||||||
|
.expect("new_session");
|
||||||
|
|
||||||
|
wait_for_awaiting(&fx, id, true, "an idle shell sits in read()");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case the old heuristic got wrong. It walked `children` to the deepest
|
||||||
|
/// descendant, so it landed on the background `sleep` — which is not on the
|
||||||
|
/// terminal — and reported the session as *not* awaiting input, while the
|
||||||
|
/// foreground process was blocked on `read`.
|
||||||
|
#[test]
|
||||||
|
fn a_foreground_reader_with_a_background_helper_is_awaiting_input() {
|
||||||
|
let fx = TmuxFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let script = cwd.path().join("agentish.sh");
|
||||||
|
std::fs::write(&script, "#!/bin/bash\nsleep 300 &\nread -r line\n").unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
fx.tmux
|
||||||
|
.new_session(
|
||||||
|
id,
|
||||||
|
cwd.path(),
|
||||||
|
&format!("bash --noprofile --norc {}", script.display()),
|
||||||
|
)
|
||||||
|
.expect("new_session");
|
||||||
|
|
||||||
|
// Wait for the helper itself. Asserting before it exists would pass
|
||||||
|
// against a deepest-descendant walk too, because until then the newest
|
||||||
|
// process in the tree *is* the one blocked on read.
|
||||||
|
let pane = fx.tmux.pane_info(id).expect("pane_info").expect("pane");
|
||||||
|
wait_until(
|
||||||
|
|| subtree_has_comm(pane.pane_pid, "sleep"),
|
||||||
|
"the background helper to be spawned",
|
||||||
|
);
|
||||||
|
|
||||||
|
wait_for_awaiting(
|
||||||
|
&fx,
|
||||||
|
id,
|
||||||
|
true,
|
||||||
|
"a foreground reader must win over a newer background child",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pure timer sleep also parks in 'S' with the terminal inherited; it must
|
||||||
|
/// read as idle, not as waiting on the user.
|
||||||
|
#[test]
|
||||||
|
fn a_foreground_sleep_is_not_awaiting_input() {
|
||||||
|
let fx = TmuxFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
fx.tmux
|
||||||
|
.new_session(id, cwd.path(), "sleep 300")
|
||||||
|
.expect("new_session");
|
||||||
|
|
||||||
|
wait_for_awaiting(&fx, id, false, "a timer sleep is not user input");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any process under `root` (inclusive) has this `comm`.
|
||||||
|
fn subtree_has_comm(root: i32, comm: &str) -> bool {
|
||||||
|
let mut queue = vec![root];
|
||||||
|
while let Some(pid) = queue.pop() {
|
||||||
|
if std::fs::read_to_string(format!("/proc/{pid}/comm")).is_ok_and(|c| c.trim() == comm) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
queue.extend(
|
||||||
|
std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children"))
|
||||||
|
.map(|s| {
|
||||||
|
s.split_whitespace()
|
||||||
|
.filter_map(|x| x.parse::<i32>().ok())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block until `cond` holds, so a test asserts on the state it means to
|
||||||
|
/// exercise rather than on whatever the tree looked like first.
|
||||||
|
fn wait_until(cond: impl Fn() -> bool, what: &str) {
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
if cond() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
panic!("timed out waiting for {what}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `pgrp` and `tpgid` for a pid straight from `/proc`, so the test can
|
||||||
|
/// wait for the exact condition it means to exercise rather than a proxy.
|
||||||
|
fn pgrp_and_tpgid(pid: i32) -> Option<(i32, i32)> {
|
||||||
|
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||||
|
let close = stat.rfind(')')?;
|
||||||
|
let f: Vec<&str> = stat[close + 1..].split_whitespace().collect();
|
||||||
|
Some((f.get(2)?.parse().ok()?, f.get(5)?.parse().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `tpgid` names a process *group*, and its leader can exit while another
|
||||||
|
/// member still holds the terminal — a foreground pipeline whose first stage
|
||||||
|
/// finished. Reading `/proc/<tpgid>` directly then finds nothing and reports
|
||||||
|
/// idle, so the group has to be searched for a live member.
|
||||||
|
///
|
||||||
|
/// Needs an interactive shell: job control is what puts a pipeline in its own
|
||||||
|
/// process group, and that is how the daemon spawns commands
|
||||||
|
/// (`$SHELL -lic '<cmd>'`).
|
||||||
|
#[test]
|
||||||
|
fn a_foreground_pipeline_whose_leader_exited_is_still_awaiting_input() {
|
||||||
|
let fx = TmuxFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
fx.tmux
|
||||||
|
.new_session(id, cwd.path(), "bash --noprofile --norc -i")
|
||||||
|
.expect("new_session");
|
||||||
|
wait_for_awaiting(&fx, id, true, "the shell must settle first");
|
||||||
|
|
||||||
|
// `true` leads the group and exits immediately; the right-hand stage
|
||||||
|
// survives, blocked reading the terminal.
|
||||||
|
fx.tmux
|
||||||
|
.send_keys(id, b"true | { read -r line < /dev/tty; }\n")
|
||||||
|
.expect("send_keys");
|
||||||
|
|
||||||
|
// Wait for the state this test exists for: the foreground group is no
|
||||||
|
// longer the shell's own, and its leader is gone. Asserting before that
|
||||||
|
// would pass against a leader-only lookup, because the shell is still
|
||||||
|
// foreground and alive.
|
||||||
|
let pane = fx.tmux.pane_info(id).expect("pane_info").expect("pane");
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut reached = false;
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
if let Some((pgrp, tpgid)) = pgrp_and_tpgid(pane.pane_pid) {
|
||||||
|
if tpgid > 0 && tpgid != pgrp && pgrp_and_tpgid(tpgid).is_none() {
|
||||||
|
reached = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
reached,
|
||||||
|
"never observed a foreground group whose leader had exited"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()),
|
||||||
|
"a surviving pipeline member still holds the tty"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//! End-to-end tests for the per-session attach WS endpoint:
|
//! 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
|
//! - PTY bytes round-trip through real tmux
|
||||||
//! - multi-attach: two clients see the same output
|
//! - multi-attach: two clients see the same output
|
||||||
|
|
||||||
@@ -9,11 +9,10 @@ use std::collections::HashMap;
|
|||||||
use std::net::{Ipv4Addr, SocketAddr};
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::{Duration as ChronoDuration, Utc};
|
|
||||||
use common::DaemonFixture;
|
use common::DaemonFixture;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_daemon::{attach, issue_jwt, JwtVerifier, PairAttachVerifier, SessionManager};
|
use maestro_daemon::{local_api, SessionManager};
|
||||||
use maestro_protocol::{CreateSessionRequest, JwtClaims};
|
use maestro_protocol::CreateSessionRequest;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
@@ -21,8 +20,6 @@ use tokio_tungstenite::tungstenite::Message as WsMessage;
|
|||||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const TOKEN: &str = "shared-secret-for-tests";
|
|
||||||
|
|
||||||
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||||
|
|
||||||
struct Harness {
|
struct Harness {
|
||||||
@@ -32,12 +29,15 @@ struct Harness {
|
|||||||
_fx: DaemonFixture,
|
_fx: DaemonFixture,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_daemon_ws(daemon_id: &str) -> Harness {
|
async fn spawn_daemon_ws() -> Harness {
|
||||||
let fx = DaemonFixture::new(daemon_id);
|
let fx = DaemonFixture::new();
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
let verifier = JwtVerifier::new(TOKEN, daemon_id);
|
let router = local_api::router(local_api::state_for(
|
||||||
let pair_verifier = PairAttachVerifier::new(TOKEN, daemon_id);
|
manager.clone(),
|
||||||
let router = attach::router(manager.clone(), verifier, pair_verifier);
|
"test-host",
|
||||||
|
"/home/test",
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -58,25 +58,12 @@ async fn spawn_daemon_ws(daemon_id: &str) -> Harness {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token_for(session_id: Uuid, daemon_id: &str, valid_seconds: i64) -> String {
|
#[allow(clippy::result_large_err)]
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_attach(
|
async fn connect_attach(
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
session: Uuid,
|
session: Uuid,
|
||||||
token: &str,
|
|
||||||
) -> Result<WsClient, tokio_tungstenite::tungstenite::Error> {
|
) -> 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)
|
connect_async(url).await.map(|(ws, _)| ws)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +73,6 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
|||||||
let s = h
|
let s = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: cwd_path,
|
cwd: cwd_path,
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -100,42 +86,17 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rejects_bad_token() {
|
async fn rejects_unknown_session() {
|
||||||
let h = spawn_daemon_ws("laptop").await;
|
let h = spawn_daemon_ws().await;
|
||||||
let id = create_bash_session(&h).await;
|
let err = connect_attach(h.addr, Uuid::new_v4()).await.unwrap_err();
|
||||||
let err = connect_attach(h.addr, id, "not-a-jwt").await.unwrap_err();
|
assert!(format!("{err}").contains("404"));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pty_round_trip_through_real_tmux() {
|
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 id = create_bash_session(&h).await;
|
||||||
let token = token_for(id, "laptop", 300);
|
let mut ws = connect_attach(h.addr, id).await.unwrap();
|
||||||
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
|
|
||||||
|
|
||||||
ws.send(WsMessage::Binary(b"echo MAESTRO_RT_OK\n".to_vec()))
|
ws.send(WsMessage::Binary(b"echo MAESTRO_RT_OK\n".to_vec()))
|
||||||
.await
|
.await
|
||||||
@@ -164,12 +125,11 @@ async fn pty_round_trip_through_real_tmux() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn multi_attach_broadcasts_to_every_client() {
|
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 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_a = connect_attach(h.addr, id).await.unwrap();
|
||||||
let mut ws_b = connect_attach(h.addr, id, &token).await.unwrap();
|
let mut ws_b = connect_attach(h.addr, id).await.unwrap();
|
||||||
|
|
||||||
// give the broadcast subscribers a moment to register
|
// give the broadcast subscribers a moment to register
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
@@ -216,10 +176,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
|
// 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
|
// watcher reaps it, and the broadcast channel closes — but the WS used to
|
||||||
// be dropped silently, leaving the client hung in raw mode.
|
// 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 id = create_bash_session(&h).await;
|
||||||
let token = token_for(id, "laptop", 300);
|
let mut ws = connect_attach(h.addr, id).await.unwrap();
|
||||||
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
@@ -251,10 +210,9 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn sends_close_frame_when_session_is_killed() {
|
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 id = create_bash_session(&h).await;
|
||||||
let token = token_for(id, "laptop", 300);
|
let mut ws = connect_attach(h.addr, id).await.unwrap();
|
||||||
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
|
|
||||||
|
|
||||||
// give the broadcast subscriber a moment to register before killing.
|
// give the broadcast subscriber a moment to register before killing.
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
@@ -287,10 +245,9 @@ async fn sends_close_frame_when_session_is_killed() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ping_text_frame_gets_pong() {
|
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 id = create_bash_session(&h).await;
|
||||||
let token = token_for(id, "laptop", 300);
|
let mut ws = connect_attach(h.addr, id).await.unwrap();
|
||||||
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
|
|
||||||
|
|
||||||
let ping = serde_json::to_string(&maestro_protocol::ClientControl::Ping).unwrap();
|
let ping = serde_json::to_string(&maestro_protocol::ClientControl::Ping).unwrap();
|
||||||
ws.send(WsMessage::Text(ping)).await.unwrap();
|
ws.send(WsMessage::Text(ping)).await.unwrap();
|
||||||
@@ -315,11 +272,10 @@ async fn ping_text_frame_gets_pong() {
|
|||||||
async fn second_attach_sees_replay_buffer_of_prior_output() {
|
async fn second_attach_sees_replay_buffer_of_prior_output() {
|
||||||
// NAP-229: a fresh attach must receive the buffered scrollback before
|
// NAP-229: a fresh attach must receive the buffered scrollback before
|
||||||
// any live frames, so reconnecting to an idle prompt isn't blank.
|
// 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 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.
|
// Generate output the bash will echo back.
|
||||||
a.send(WsMessage::Binary(b"echo MAESTRO_REPLAY_MARK\n".to_vec()))
|
a.send(WsMessage::Binary(b"echo MAESTRO_REPLAY_MARK\n".to_vec()))
|
||||||
.await
|
.await
|
||||||
@@ -349,7 +305,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
|
// Now attach a fresh client. It should see the prior MARK in the replay
|
||||||
// even though no new output has happened since.
|
// 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 saw_replay = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
while let Some(Ok(msg)) = b.next().await {
|
while let Some(Ok(msg)) = b.next().await {
|
||||||
@@ -369,3 +325,57 @@ async fn second_attach_sees_replay_buffer_of_prior_output() {
|
|||||||
"fresh attach did not receive replay containing prior output"
|
"fresh attach did not receive replay containing prior output"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The point of keeping a dead session: attach afterwards and read what it
|
||||||
|
/// printed before it died. The stream then ends rather than hanging.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attaching_to_a_dead_session_replays_its_final_output() {
|
||||||
|
let h = spawn_daemon_ws().await;
|
||||||
|
let id = create_bash_session(&h).await;
|
||||||
|
|
||||||
|
// Produce output, then let the session die out-of-band.
|
||||||
|
{
|
||||||
|
let mut ws = connect_attach(h.addr, id).await.unwrap();
|
||||||
|
ws.send(WsMessage::Binary(b"echo last-words\n".to_vec()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
}
|
||||||
|
h.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||||
|
h._fx.tmux.tmux.kill_session(id).expect("kill tmux");
|
||||||
|
|
||||||
|
// Wait for the daemon to notice.
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(3);
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
if h.manager.status(id).await == Some(maestro_protocol::SessionStatus::Dead) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
h.manager.status(id).await,
|
||||||
|
Some(maestro_protocol::SessionStatus::Dead),
|
||||||
|
"session should be marked dead, not removed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut ws = connect_attach(h.addr, id)
|
||||||
|
.await
|
||||||
|
.expect("a dead session must still accept an attach");
|
||||||
|
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(3);
|
||||||
|
while std::time::Instant::now() < deadline {
|
||||||
|
match tokio::time::timeout(Duration::from_millis(200), ws.next()).await {
|
||||||
|
Ok(Some(Ok(WsMessage::Binary(b)))) => seen.extend_from_slice(&b),
|
||||||
|
Ok(Some(Ok(WsMessage::Close(_)))) | Ok(None) => break,
|
||||||
|
Ok(Some(Ok(_))) => continue,
|
||||||
|
Ok(Some(Err(_))) => break,
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(&seen);
|
||||||
|
assert!(
|
||||||
|
text.contains("last-words"),
|
||||||
|
"replay of a dead session lost its final output: {text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -114,29 +115,27 @@ pub struct DaemonFixture {
|
|||||||
pub tmux: TmuxFixture,
|
pub tmux: TmuxFixture,
|
||||||
pub manager: SessionManager,
|
pub manager: SessionManager,
|
||||||
pub state_dir: TempDir,
|
pub state_dir: TempDir,
|
||||||
pub worktree_root: TempDir,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonFixture {
|
impl DaemonFixture {
|
||||||
pub fn new(daemon_id: &str) -> Self {
|
pub fn new() -> Self {
|
||||||
Self::build(daemon_id, None)
|
Self::build(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Variant whose manager has a durable spawn history (#24) attached, with
|
/// Variant whose manager has a durable spawn history (#24) attached, with
|
||||||
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
|
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
|
||||||
pub fn new_with_history(daemon_id: &str, retention_days: u32) -> Self {
|
pub fn new_with_history(retention_days: u32) -> Self {
|
||||||
Self::build(daemon_id, Some(retention_days))
|
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 tmux = TmuxFixture::new();
|
||||||
let state_dir = TempDir::new().unwrap();
|
let state_dir = TempDir::new().unwrap();
|
||||||
let worktree_root = TempDir::new().unwrap();
|
|
||||||
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||||
let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf());
|
// Default placement: worktrees land under their base repo (#56).
|
||||||
|
let worktree_mgr = WorktreeManager::new(None);
|
||||||
let mut manager = SessionManager::new(
|
let mut manager = SessionManager::new(
|
||||||
daemon_id.to_string(),
|
Arc::new(tmux.tmux.clone()),
|
||||||
tmux.tmux.clone(),
|
|
||||||
sidecar,
|
sidecar,
|
||||||
worktree_mgr,
|
worktree_mgr,
|
||||||
state_dir.path().join("fifos"),
|
state_dir.path().join("fifos"),
|
||||||
@@ -149,7 +148,6 @@ impl DaemonFixture {
|
|||||||
tmux,
|
tmux,
|
||||||
manager,
|
manager,
|
||||||
state_dir,
|
state_dir,
|
||||||
worktree_root,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
//! Integration tests for the daemon's local HTTP API + broker WS (NAP-243).
|
//! Integration tests for the daemon's HTTP + WS API (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.
|
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
@@ -11,8 +8,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use common::DaemonFixture;
|
use common::DaemonFixture;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_daemon::{local_api, BrokerHandle, CatalogMirror, LocalEventBus};
|
use maestro_daemon::local_api;
|
||||||
use maestro_protocol::{CreateSessionRequest, DaemonInfo, RegistryEvent, Session};
|
use maestro_protocol::{CreateSessionRequest, Session};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
@@ -24,42 +21,22 @@ type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
|||||||
struct Harness {
|
struct Harness {
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
manager: maestro_daemon::SessionManager,
|
manager: maestro_daemon::SessionManager,
|
||||||
catalog: CatalogMirror,
|
|
||||||
_shutdown: oneshot::Sender<()>,
|
_shutdown: oneshot::Sender<()>,
|
||||||
_fx: DaemonFixture,
|
_fx: DaemonFixture,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
|
async fn spawn(hostname: &str) -> Harness {
|
||||||
let fx = DaemonFixture::new(daemon_id);
|
let fx = DaemonFixture::new();
|
||||||
let manager = fx.manager.clone();
|
let manager = fx.manager.clone();
|
||||||
let catalog = CatalogMirror::new();
|
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
|
||||||
let events = LocalEventBus::new();
|
// a known set (#26).
|
||||||
// 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).
|
|
||||||
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
|
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(),
|
manager.clone(),
|
||||||
hostname,
|
hostname,
|
||||||
daemon_url,
|
|
||||||
"/home/test",
|
"/home/test",
|
||||||
presets,
|
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)))
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -75,7 +52,6 @@ async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
|
|||||||
Harness {
|
Harness {
|
||||||
addr,
|
addr,
|
||||||
manager,
|
manager,
|
||||||
catalog,
|
|
||||||
_shutdown: tx,
|
_shutdown: tx,
|
||||||
_fx: fx,
|
_fx: fx,
|
||||||
}
|
}
|
||||||
@@ -85,100 +61,27 @@ fn url(addr: SocketAddr, path: &str) -> String {
|
|||||||
format!("http://{addr}{path}")
|
format!("http://{addr}{path}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ws_url(addr: SocketAddr, path: &str) -> String {
|
||||||
|
format!("ws://{addr}{path}")
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn web_info_returns_daemon_identity() {
|
async fn info_returns_daemon_identity() {
|
||||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
let h = spawn("naps-laptop").await;
|
||||||
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/web/info"))
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp["daemon_id"], "laptop");
|
|
||||||
assert_eq!(resp["hostname"], "naps-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());
|
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]
|
#[tokio::test]
|
||||||
async fn list_sessions_starts_empty_and_reflects_creates() {
|
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"))
|
let empty: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||||
.await
|
.await
|
||||||
@@ -189,8 +92,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
|||||||
assert!(empty.is_empty());
|
assert!(empty.is_empty());
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
cwd: "/tmp".into(),
|
||||||
cwd: h.manager.daemon_id().into(),
|
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
metadata: HashMap::new(),
|
metadata: HashMap::new(),
|
||||||
@@ -205,7 +107,6 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
|||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(created.daemon_id, "laptop");
|
|
||||||
|
|
||||||
let after: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
let after: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||||
.await
|
.await
|
||||||
@@ -218,37 +119,11 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn post_to_peer_daemon_id_brokers_and_503s_without_registry_link() {
|
async fn info_advertises_spawn_presets() {
|
||||||
// Cross-daemon create now brokers to the owner over the registry control
|
// #26: the daemon's presets ride the info payload so the spawn form needs
|
||||||
// link (NAP-327). This harness has no registry link (the broker starts
|
// no extra round-trip. Built-ins + the implicit custom.
|
||||||
// disconnected), so the daemon fails fast with a retryable 503 instead of
|
let h = spawn("naps-laptop").await;
|
||||||
// the old 501 — proving it attempts to broker rather than refusing.
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||||
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"))
|
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.json()
|
.json()
|
||||||
@@ -264,11 +139,11 @@ async fn web_info_advertises_spawn_presets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn recent_dirs_self_returns_empty_without_history() {
|
async fn recent_dirs_returns_empty_without_history() {
|
||||||
// #26: the self-path reads this daemon's spawn history. The test fixture
|
// #26: reads this daemon's spawn history. The test fixture wires no
|
||||||
// wires no history, so the list is empty but the endpoint is well-formed.
|
// 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 h = spawn("naps-laptop").await;
|
||||||
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/daemons/laptop/recent-dirs"))
|
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/recent-dirs"))
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.json()
|
.json()
|
||||||
@@ -279,15 +154,15 @@ async fn recent_dirs_self_returns_empty_without_history() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gitroot_self_probes_path() {
|
async fn gitroot_probes_path() {
|
||||||
// #26: the self-path resolves the query against the daemon home dir and
|
// #26: resolves the query against the daemon home dir and walks up for a
|
||||||
// walks up for a `.git`. A `/tmp`-rooted temp dir is not in a repo → null.
|
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
|
||||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
let h = spawn("naps-laptop").await;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let resp: serde_json::Value = reqwest::get(url(
|
let resp: serde_json::Value = reqwest::get(url(
|
||||||
h.addr,
|
h.addr,
|
||||||
&format!(
|
&format!(
|
||||||
"/api/daemons/laptop/gitroot?path={}",
|
"/api/gitroot?path={}",
|
||||||
urlencoding::encode(&tmp.path().to_string_lossy())
|
urlencoding::encode(&tmp.path().to_string_lossy())
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
@@ -299,22 +174,9 @@ async fn gitroot_self_probes_path() {
|
|||||||
assert!(resp.get("root").is_none() || resp["root"].is_null());
|
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]
|
#[tokio::test]
|
||||||
async fn delete_unknown_session_returns_404() {
|
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()
|
let resp = reqwest::Client::new()
|
||||||
.delete(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
|
.delete(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
|
||||||
.send()
|
.send()
|
||||||
@@ -325,11 +187,10 @@ async fn delete_unknown_session_returns_404() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_owned_session_kills_it() {
|
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
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "sleep 60".into(),
|
command: "sleep 60".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -348,12 +209,11 @@ async fn delete_owned_session_kills_it() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn broker_ws_attaches_to_local_session() {
|
async fn attach_ws_streams_pty_bytes() {
|
||||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
let h = spawn("naps-laptop").await;
|
||||||
let session = h
|
let session = h
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -367,7 +227,7 @@ async fn broker_ws_attaches_to_local_session() {
|
|||||||
let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap();
|
let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap();
|
||||||
|
|
||||||
// Provoke output. The bash process should echo this back via the PTY.
|
// 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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -375,7 +235,7 @@ async fn broker_ws_attaches_to_local_session() {
|
|||||||
loop {
|
loop {
|
||||||
match ws.next().await {
|
match ws.next().await {
|
||||||
Some(Ok(WsMessage::Binary(bytes))) => {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,12 +246,12 @@ async fn broker_ws_attaches_to_local_session() {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.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]
|
#[tokio::test]
|
||||||
async fn broker_ws_for_unknown_session_returns_404_not_502() {
|
async fn attach_ws_for_unknown_session_returns_404() {
|
||||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
let h = spawn("naps-laptop").await;
|
||||||
let ws_url = format!("ws://{}/api/attach/{}", h.addr, Uuid::new_v4());
|
let ws_url = format!("ws://{}/api/attach/{}", h.addr, Uuid::new_v4());
|
||||||
let err = connect_async(ws_url)
|
let err = connect_async(ws_url)
|
||||||
.await
|
.await
|
||||||
@@ -402,3 +262,103 @@ async fn broker_ws_for_unknown_session_returns_404_not_502() {
|
|||||||
"{msg}"
|
"{msg}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `WS /api/events` had no daemon-level test: the Snapshot-then-deltas contract
|
||||||
|
/// and the subscribe-before-snapshot ordering were only described in a comment.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
||||||
|
let h = spawn("konishi").await;
|
||||||
|
let cwd = tempfile::TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events"))
|
||||||
|
.await
|
||||||
|
.expect("events ws");
|
||||||
|
|
||||||
|
match next_event(&mut ws).await {
|
||||||
|
maestro_protocol::CatalogEvent::Snapshot { sessions } => assert!(sessions.is_empty()),
|
||||||
|
other => panic!("first frame must be a Snapshot, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = h
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
match next_event(&mut ws).await {
|
||||||
|
maestro_protocol::CatalogEvent::SessionAdded { session } => {
|
||||||
|
assert_eq!(session.id, created.id);
|
||||||
|
}
|
||||||
|
other => panic!("expected SessionAdded, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.manager.kill(created.id).await.expect("kill");
|
||||||
|
|
||||||
|
// Activity ticks may interleave; the removal is what we are waiting for.
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
assert!(
|
||||||
|
tokio::time::Instant::now() < deadline,
|
||||||
|
"timed out waiting for SessionRemoved"
|
||||||
|
);
|
||||||
|
if let maestro_protocol::CatalogEvent::SessionRemoved { id } = next_event(&mut ws).await {
|
||||||
|
assert_eq!(id, created.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each subscriber has its own lag domain now that the publisher task is gone:
|
||||||
|
/// two clients connected at different times both get their own Snapshot.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn every_events_subscriber_gets_its_own_snapshot() {
|
||||||
|
let h = spawn("konishi").await;
|
||||||
|
let cwd = tempfile::TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let (mut first, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap();
|
||||||
|
let _ = next_event(&mut first).await;
|
||||||
|
|
||||||
|
h.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
|
||||||
|
let (mut second, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap();
|
||||||
|
match next_event(&mut second).await {
|
||||||
|
maestro_protocol::CatalogEvent::Snapshot { sessions } => {
|
||||||
|
assert_eq!(
|
||||||
|
sessions.len(),
|
||||||
|
1,
|
||||||
|
"a late subscriber must see current state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Snapshot, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
|
||||||
|
loop {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(5), ws.next())
|
||||||
|
.await
|
||||||
|
.expect("events ws timed out")
|
||||||
|
{
|
||||||
|
Some(Ok(WsMessage::Text(t))) => {
|
||||||
|
return serde_json::from_str(&t).expect("decode catalog event")
|
||||||
|
}
|
||||||
|
Some(Ok(_)) => continue,
|
||||||
|
other => panic!("unexpected events frame: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
//! The session manager's decisions, driven through [`FakePanes`].
|
||||||
|
//!
|
||||||
|
//! These are the branches that used to need a forked tmux server to reach:
|
||||||
|
//! spawn rollback ordering, liveness diffing, and activity change-detection.
|
||||||
|
//! Nothing here spawns a process, so they run everywhere and in milliseconds.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use maestro_daemon::panes::{Call, FakePanes, Op};
|
||||||
|
use maestro_daemon::session::{SessionError, SessionManager};
|
||||||
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
|
use maestro_daemon::tmux::PaneInfo;
|
||||||
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
|
use maestro_protocol::{ActivityState, CatalogEvent, CreateSessionRequest, SessionStatus};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use tokio::sync::broadcast::error::TryRecvError;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
struct Fx {
|
||||||
|
manager: SessionManager,
|
||||||
|
fake: Arc<FakePanes>,
|
||||||
|
tmp: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fx {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
|
||||||
|
let tmp = TempDir::new().expect("tempdir");
|
||||||
|
let fake = Arc::new(FakePanes::new());
|
||||||
|
let fifo_dir = tmp.path().join("fifos");
|
||||||
|
std::fs::create_dir_all(&fifo_dir).expect("fifo dir");
|
||||||
|
let manager = SessionManager::new(
|
||||||
|
fake.clone(),
|
||||||
|
SidecarStore::new(pick(tmp.path())),
|
||||||
|
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
|
||||||
|
fifo_dir,
|
||||||
|
);
|
||||||
|
Self { manager, fake, tmp }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, command: &str) -> CreateSessionRequest {
|
||||||
|
CreateSessionRequest {
|
||||||
|
cwd: self.tmp.path().to_path_buf(),
|
||||||
|
command: command.into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll until `id` reaches `want`.
|
||||||
|
async fn wait_for_status(manager: &SessionManager, id: uuid::Uuid, want: SessionStatus) {
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
if manager.status(id).await == Some(want) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
panic!("timed out waiting for {id} to become {want:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NAP-260 rollback, asserted as an *ordering* rather than an end state.
|
||||||
|
///
|
||||||
|
/// Replaces a test that forced the sidecar failure with `chmod 0o500` and then
|
||||||
|
/// returned early when it detected root — so on CI it asserted nothing. Here
|
||||||
|
/// the sidecar directory cannot be created because a regular file sits where
|
||||||
|
/// its parent must be, which no uid can work around.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_kills_the_tmux_session_when_the_sidecar_write_fails() {
|
||||||
|
let fx = Fx::with_sidecar_dir(|tmp| {
|
||||||
|
let blocker = tmp.join("blocker");
|
||||||
|
std::fs::write(&blocker, b"not a directory").expect("blocker file");
|
||||||
|
blocker.join("sidecars")
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = fx.manager.create(fx.request("sleep 30")).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(res, Err(SessionError::Sidecar(_))),
|
||||||
|
"expected the sidecar write to fail, got {res:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let calls = fx.fake.calls();
|
||||||
|
assert_eq!(
|
||||||
|
calls.len(),
|
||||||
|
2,
|
||||||
|
"expected spawn then rollback kill, got {calls:?}"
|
||||||
|
);
|
||||||
|
assert!(matches!(calls[0], Call::NewSessionPiped { .. }));
|
||||||
|
let Call::NewSessionPiped { id, .. } = calls[0] else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
calls[1],
|
||||||
|
Call::KillSession(id),
|
||||||
|
"the rollback must kill the session it just spawned"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!fx.fake.is_live(id), "create left a ghost tmux session");
|
||||||
|
assert!(fx.manager.snapshot().await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failed spawn must not roll back anything, least of all kill a session id
|
||||||
|
/// tmux never accepted.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_does_not_roll_back_when_the_spawn_itself_fails() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
fx.fake.fail(Op::NewSessionPiped, "no server running");
|
||||||
|
|
||||||
|
let res = fx.manager.create(fx.request("sleep 30")).await;
|
||||||
|
assert!(res.is_err(), "expected the spawn to fail, got {res:?}");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fx.fake.calls().len(),
|
||||||
|
1,
|
||||||
|
"only the failed spawn should have been attempted: {:?}",
|
||||||
|
fx.fake.calls()
|
||||||
|
);
|
||||||
|
assert!(fx.manager.snapshot().await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A restarted daemon adopts whatever tmux still holds, and must re-pipe each
|
||||||
|
/// one — without that the adopted session produces no output.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recover_adopts_live_sessions_and_repipes_them() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let orphan = Uuid::new_v4();
|
||||||
|
fx.fake.add_live(orphan);
|
||||||
|
|
||||||
|
let adopted = fx.manager.recover().await.expect("recover");
|
||||||
|
assert_eq!(adopted, 1);
|
||||||
|
|
||||||
|
let snap = fx.manager.snapshot().await;
|
||||||
|
assert_eq!(snap.len(), 1);
|
||||||
|
assert_eq!(snap[0].id, orphan);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
fx.fake.calls().contains(&Call::PipePane(orphan)),
|
||||||
|
"adopted session was never re-piped: {:?}",
|
||||||
|
fx.fake.calls()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session tmux lost is marked `Dead` and kept, so the operator can still see
|
||||||
|
/// it exited and read what it printed. It is not removed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn liveness_marks_the_lost_session_dead_and_keeps_it() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let keep = fx.manager.create(fx.request("keep")).await.expect("keep");
|
||||||
|
let lose = fx.manager.create(fx.request("lose")).await.expect("lose");
|
||||||
|
let mut events = fx.manager.events();
|
||||||
|
|
||||||
|
fx.fake.remove_live(lose.id);
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||||
|
wait_for_status(&fx.manager, lose.id, SessionStatus::Dead).await;
|
||||||
|
|
||||||
|
let all = fx.manager.snapshot().await;
|
||||||
|
assert_eq!(all.len(), 2, "the dead session must stay in the catalog");
|
||||||
|
let survivor = all.iter().find(|s| s.id == keep.id).expect("keep");
|
||||||
|
assert_eq!(survivor.status, SessionStatus::Running);
|
||||||
|
|
||||||
|
let mut removed = Vec::new();
|
||||||
|
while let Ok(ev) = events.try_recv() {
|
||||||
|
if let CatalogEvent::SessionRemoved { id } = ev {
|
||||||
|
removed.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(removed.is_empty(), "nothing should be removed: {removed:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The transition fires once. Without the Running filter the pass re-announces
|
||||||
|
/// the same dead session on every tick, forever.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_dead_session_is_announced_only_once() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
let mut events = fx.manager.events();
|
||||||
|
|
||||||
|
fx.fake.remove_live(s.id);
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||||
|
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
|
||||||
|
|
||||||
|
let mut dead_updates = 0;
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_millis(300);
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
if let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
|
||||||
|
if session.status == SessionStatus::Dead {
|
||||||
|
dead_updates += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
assert_eq!(dead_updates, 1, "expected exactly one Dead announcement");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismissing a dead session releases it — without asking tmux to kill
|
||||||
|
/// something tmux already lost.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn killing_a_dead_session_dismisses_it_without_touching_tmux() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
fx.fake.remove_live(s.id);
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||||
|
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
|
||||||
|
|
||||||
|
fx.manager.kill(s.id).await.expect("dismiss");
|
||||||
|
|
||||||
|
assert!(fx.manager.snapshot().await.is_empty());
|
||||||
|
assert!(
|
||||||
|
!fx.fake
|
||||||
|
.calls()
|
||||||
|
.iter()
|
||||||
|
.any(|c| matches!(c, Call::KillSession(k) if *k == s.id)),
|
||||||
|
"kill-session was called on a session tmux had already lost: {:?}",
|
||||||
|
fx.fake.calls()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dead session has no pane, so input is dropped rather than surfacing as a
|
||||||
|
/// tmux failure the client would have to interpret.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_dead_session_is_read_only() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
fx.fake.remove_live(s.id);
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||||
|
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.send_keys(s.id, b"ls\n")
|
||||||
|
.await
|
||||||
|
.expect("send_keys");
|
||||||
|
fx.manager.resize(s.id, 80, 24).await.expect("resize");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!fx.fake
|
||||||
|
.calls()
|
||||||
|
.iter()
|
||||||
|
.any(|c| matches!(c, Call::SendKeys { .. } | Call::ResizeWindow { .. })),
|
||||||
|
"input reached tmux for a dead session: {:?}",
|
||||||
|
fx.fake.calls()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CreateSessionRequest.env` was accepted, stored, echoed back to clients, and
|
||||||
|
/// then dropped: `new_session_piped` had no env parameter at all.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn requested_env_reaches_tmux() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let mut req = fx.request("bash");
|
||||||
|
req.env.insert("FOO".into(), "bar".into());
|
||||||
|
req.env.insert("EMPTY".into(), String::new());
|
||||||
|
|
||||||
|
fx.manager.create(req).await.expect("create");
|
||||||
|
|
||||||
|
let want: BTreeMap<String, String> = [
|
||||||
|
("EMPTY".to_string(), String::new()),
|
||||||
|
("FOO".to_string(), "bar".to_string()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
match fx.fake.calls().first() {
|
||||||
|
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
|
||||||
|
other => panic!("expected a spawn call, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_env_key_tmux_would_misparse_is_rejected() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let mut req = fx.request("bash");
|
||||||
|
req.env.insert("A=B".into(), "x".into());
|
||||||
|
|
||||||
|
let err = fx.manager.create(req).await.expect_err("should reject");
|
||||||
|
assert_eq!(err.to_error_info().code, "invalid_env");
|
||||||
|
assert!(
|
||||||
|
fx.fake.calls().is_empty(),
|
||||||
|
"nothing should have been spawned: {:?}",
|
||||||
|
fx.fake.calls()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If `list_sessions` fails the pass has no idea what is alive. Reaping on that
|
||||||
|
/// basis would kill the whole catalog.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn liveness_reaps_nothing_when_tmux_cannot_be_listed() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let alive = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||||
|
|
||||||
|
fx.fake.fail(Op::ListSessions, "connection refused");
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||||
|
|
||||||
|
// Give the watcher several passes to do the wrong thing.
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
let snap = fx.manager.snapshot().await;
|
||||||
|
assert_eq!(
|
||||||
|
snap.len(),
|
||||||
|
1,
|
||||||
|
"a failed list must not be read as 'everything died'"
|
||||||
|
);
|
||||||
|
assert_eq!(snap[0].id, alive.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn activity_pass_emits_updated_only_when_something_changed() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let session = fx.manager.create(fx.request("zsh")).await.expect("create");
|
||||||
|
let mut events = fx.manager.events();
|
||||||
|
|
||||||
|
fx.fake.set_pane_info(
|
||||||
|
session.id,
|
||||||
|
PaneInfo {
|
||||||
|
// A pid nothing can be reading from, so `awaiting_input` is a
|
||||||
|
// deterministic false rather than a probe of this machine.
|
||||||
|
pane_pid: i32::MAX,
|
||||||
|
command: "claude".into(),
|
||||||
|
tty: "/dev/null".into(),
|
||||||
|
current_path: "/srv/work".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||||
|
|
||||||
|
let updated = wait_for_updated(&mut events).await;
|
||||||
|
assert_eq!(updated.foreground.as_deref(), Some("claude"));
|
||||||
|
assert_eq!(
|
||||||
|
updated.current_cwd.as_deref(),
|
||||||
|
Some(std::path::Path::new("/srv/work"))
|
||||||
|
);
|
||||||
|
// Still inside `activity::ACTIVE_WINDOW` (3s) since the spawn, so the
|
||||||
|
// change here is `foreground`, not the activity state.
|
||||||
|
assert_eq!(updated.activity, ActivityState::Active);
|
||||||
|
|
||||||
|
// Drain anything already queued, then confirm a steady pane stays quiet.
|
||||||
|
// Stays well inside the 3s window, so no Active -> Idle transition either.
|
||||||
|
while events.try_recv().is_ok() {}
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
assert!(
|
||||||
|
matches!(events.try_recv(), Err(TryRecvError::Empty)),
|
||||||
|
"an unchanged pane must not re-emit SessionUpdated every tick"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_updated(
|
||||||
|
events: &mut tokio::sync::broadcast::Receiver<CatalogEvent>,
|
||||||
|
) -> maestro_protocol::Session {
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
match events.try_recv() {
|
||||||
|
Ok(CatalogEvent::SessionUpdated { session }) => return session,
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(_) => tokio::time::sleep(Duration::from_millis(10)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("timed out waiting for SessionUpdated");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kill handler used to check `has()` first, which opened a window for the
|
||||||
|
/// liveness watcher to reap the session in between — turning a 404 into a 502.
|
||||||
|
/// `kill` reports the miss itself now.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn killing_an_unknown_session_is_reported_as_not_found() {
|
||||||
|
let fx = Fx::new();
|
||||||
|
let err = fx
|
||||||
|
.manager
|
||||||
|
.kill(uuid::Uuid::new_v4())
|
||||||
|
.await
|
||||||
|
.expect_err("should fail");
|
||||||
|
assert_eq!(err.to_error_info().code, "not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A construction-order mistake must be loud: a silently-ignored history leaves
|
||||||
|
/// the recent-dirs picker permanently empty.
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "before the SessionManager is cloned")]
|
||||||
|
fn attaching_spawn_history_after_cloning_panics() {
|
||||||
|
let tmp = TempDir::new().expect("tempdir");
|
||||||
|
let fake = Arc::new(FakePanes::new());
|
||||||
|
let manager = SessionManager::new(
|
||||||
|
fake,
|
||||||
|
SidecarStore::new(tmp.path().join("sidecars")),
|
||||||
|
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
|
||||||
|
tmp.path().join("fifos"),
|
||||||
|
);
|
||||||
|
let _clone = manager.clone();
|
||||||
|
let _ = manager.with_spawn_history(maestro_daemon::spawn_history::SpawnHistory::new(
|
||||||
|
tmp.path().join("history.jsonl"),
|
||||||
|
7,
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -4,22 +4,22 @@ mod common;
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::{wait_for_async, DaemonFixture};
|
use common::{init_repo, wait_for_async, DaemonFixture};
|
||||||
use maestro_daemon::SessionEvent;
|
|
||||||
use maestro_protocol::CreateSessionRequest;
|
use maestro_protocol::CreateSessionRequest;
|
||||||
|
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_then_kill_round_trip() {
|
async fn create_then_kill_round_trip() {
|
||||||
let fx = DaemonFixture::new("laptop");
|
let fx = DaemonFixture::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let session = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -55,13 +55,12 @@ async fn create_then_kill_round_trip() {
|
|||||||
// end against a real tmux + on-disk JSONL log.
|
// end against a real tmux + on-disk JSONL log.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_appends_to_spawn_history() {
|
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 cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let session = fx
|
let session = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -87,11 +86,10 @@ async fn create_appends_to_spawn_history() {
|
|||||||
// recents query is empty — the spawn itself still succeeds.
|
// recents query is empty — the spawn itself still succeeds.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_without_history_logs_nothing() {
|
async fn create_without_history_logs_nothing() {
|
||||||
let fx = DaemonFixture::new("laptop");
|
let fx = DaemonFixture::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
fx.manager
|
fx.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -104,86 +102,9 @@ async fn create_without_history_logs_nothing() {
|
|||||||
assert!(!fx.spawn_history_path().exists());
|
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
|
|
||||||
/// FIFO + tmux steps use other dirs and still succeed).
|
|
||||||
#[tokio::test]
|
|
||||||
async fn create_rolls_back_tmux_session_when_sidecar_write_fails() {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
|
|
||||||
let fx = DaemonFixture::new("laptop");
|
|
||||||
let cwd = TempDir::new().unwrap();
|
|
||||||
|
|
||||||
let sidecar_dir = fx.sidecar_dir();
|
|
||||||
std::fs::create_dir_all(&sidecar_dir).unwrap();
|
|
||||||
std::fs::set_permissions(&sidecar_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
|
|
||||||
|
|
||||||
// The failure is injected by making the sidecar dir read-only — but root
|
|
||||||
// bypasses directory permission bits, so the write would succeed and the
|
|
||||||
// rollback path never trigger. CI runs as root; detect that with a write
|
|
||||||
// probe and skip rather than report a spurious failure (the rollback is
|
|
||||||
// still covered in any non-root environment).
|
|
||||||
let probe = sidecar_dir.join(".perm_probe");
|
|
||||||
if std::fs::write(&probe, b"x").is_ok() {
|
|
||||||
let _ = std::fs::remove_file(&probe);
|
|
||||||
std::fs::set_permissions(&sidecar_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
||||||
eprintln!("skipping: sidecar dir writable despite 0o500 (running as root?)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = 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;
|
|
||||||
|
|
||||||
// Restore perms so the TempDir can be cleaned up regardless of assertions.
|
|
||||||
std::fs::set_permissions(&sidecar_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
matches!(res, Err(maestro_daemon::SessionError::Sidecar(_))),
|
|
||||||
"expected the sidecar write to fail, got {res:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The session tmux spawned must have been killed — no ghost left running.
|
|
||||||
let live = fx.tmux.tmux.list_sessions().expect("list tmux sessions");
|
|
||||||
assert!(
|
|
||||||
live.is_empty(),
|
|
||||||
"create left a ghost tmux session behind: {live:?}"
|
|
||||||
);
|
|
||||||
// And nothing in the in-memory catalog.
|
|
||||||
assert!(fx.manager.snapshot().await.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn recover_adopts_existing_tmux_sessions() {
|
async fn recover_adopts_existing_tmux_sessions() {
|
||||||
let fx = DaemonFixture::new("laptop");
|
let fx = DaemonFixture::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
// create a session, then "throw away" the SessionManager (the tmux session
|
// create a session, then "throw away" the SessionManager (the tmux session
|
||||||
@@ -191,7 +112,6 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -203,11 +123,9 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
|
|
||||||
// build a parallel manager pointing at the same dirs + same tmux server
|
// build a parallel manager pointing at the same dirs + same tmux server
|
||||||
let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir());
|
let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir());
|
||||||
let worktree_mgr =
|
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
|
||||||
maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf());
|
|
||||||
let revived = maestro_daemon::SessionManager::new(
|
let revived = maestro_daemon::SessionManager::new(
|
||||||
"laptop".to_string(),
|
Arc::new(fx.tmux.tmux.clone()),
|
||||||
fx.tmux.tmux.clone(),
|
|
||||||
sidecar,
|
sidecar,
|
||||||
worktree_mgr,
|
worktree_mgr,
|
||||||
fx.fifo_dir(),
|
fx.fifo_dir(),
|
||||||
@@ -220,49 +138,6 @@ async fn recover_adopts_existing_tmux_sessions() {
|
|||||||
assert_eq!(snap[0].command, "sleep 30");
|
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
|
/// 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
|
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
||||||
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
||||||
@@ -270,13 +145,12 @@ async fn recover_overwrites_stale_daemon_id_after_rename() {
|
|||||||
/// into the FIFO.
|
/// into the FIFO.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_captures_initial_output_in_replay_buffer() {
|
async fn create_captures_initial_output_in_replay_buffer() {
|
||||||
let fx = DaemonFixture::new("laptop");
|
let fx = DaemonFixture::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
// The shell prints the marker before doing anything else and
|
// The shell prints the marker before doing anything else and
|
||||||
// then sleeps so the session stays alive. Before NAP-253 this
|
// then sleeps so the session stays alive. Before NAP-253 this
|
||||||
@@ -318,13 +192,12 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
|||||||
/// nothing.
|
/// nothing.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
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 cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -340,7 +213,6 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|||||||
let sidecar_dir = fx.sidecar_dir();
|
let sidecar_dir = fx.sidecar_dir();
|
||||||
let fifo_dir = fx.fifo_dir();
|
let fifo_dir = fx.fifo_dir();
|
||||||
let tmux = fx.tmux.tmux.clone();
|
let tmux = fx.tmux.tmux.clone();
|
||||||
let worktree_root = fx.worktree_root.path().to_path_buf();
|
|
||||||
|
|
||||||
// Drop the original manager. Its PtyBridge closes the FIFO's only
|
// Drop the original manager. Its PtyBridge closes the FIFO's only
|
||||||
// reader, so tmux's `cat >> fifo` writer hits SIGPIPE and exits — but
|
// reader, so tmux's `cat >> fifo` writer hits SIGPIPE and exits — but
|
||||||
@@ -349,14 +221,9 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|||||||
drop(fx.manager);
|
drop(fx.manager);
|
||||||
|
|
||||||
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
|
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
|
||||||
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(worktree_root);
|
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
|
||||||
let revived = maestro_daemon::SessionManager::new(
|
let revived =
|
||||||
"laptop".to_string(),
|
maestro_daemon::SessionManager::new(Arc::new(tmux), sidecar, worktree_mgr, fifo_dir);
|
||||||
tmux,
|
|
||||||
sidecar,
|
|
||||||
worktree_mgr,
|
|
||||||
fifo_dir,
|
|
||||||
);
|
|
||||||
assert_eq!(revived.recover().await.expect("recover"), 1);
|
assert_eq!(revived.recover().await.expect("recover"), 1);
|
||||||
|
|
||||||
let mut rx = revived.subscribe_pty(s.id).await.expect("subscribe");
|
let mut rx = revived.subscribe_pty(s.id).await.expect("subscribe");
|
||||||
@@ -387,13 +254,12 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
||||||
let fx = DaemonFixture::new("laptop");
|
let fx = DaemonFixture::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "bash --noprofile --norc".into(),
|
command: "bash --noprofile --norc".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -437,14 +303,13 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn liveness_watcher_reaps_dead_sessions_and_emits_removed() {
|
async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||||
let fx = DaemonFixture::new("laptop");
|
let fx = DaemonFixture::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -463,41 +328,115 @@ async fn liveness_watcher_reaps_dead_sessions_and_emits_removed() {
|
|||||||
// daemon never finds out through its normal channels.
|
// daemon never finds out through its normal channels.
|
||||||
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||||
|
|
||||||
// Within a few watcher ticks the daemon should drop the session.
|
|
||||||
wait_for_async(Duration::from_secs(3), || async {
|
wait_for_async(Duration::from_secs(3), || async {
|
||||||
!fx.manager.has(s.id).await
|
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Sidecar + FIFO should be cleaned up too.
|
// Marked, not removed — the operator still sees it exited, and its
|
||||||
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
// artifacts stay so the output can be replayed.
|
||||||
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
assert!(fx.manager.has(s.id).await);
|
||||||
|
assert!(fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||||
|
|
||||||
// And we should have seen a Removed event for this id.
|
let mut saw_dead = false;
|
||||||
let mut saw_removed = false;
|
|
||||||
let deadline = std::time::Instant::now() + Duration::from_secs(1);
|
let deadline = std::time::Instant::now() + Duration::from_secs(1);
|
||||||
while std::time::Instant::now() < deadline && !saw_removed {
|
while std::time::Instant::now() < deadline && !saw_dead {
|
||||||
match tokio::time::timeout(Duration::from_millis(50), events.recv()).await {
|
match tokio::time::timeout(Duration::from_millis(50), events.recv()).await {
|
||||||
Ok(Ok(SessionEvent::Removed(id))) if id == s.id => saw_removed = true,
|
Ok(Ok(CatalogEvent::SessionUpdated { session }))
|
||||||
|
if session.id == s.id && session.status == SessionStatus::Dead =>
|
||||||
|
{
|
||||||
|
saw_dead = true
|
||||||
|
}
|
||||||
Ok(Ok(_)) => continue,
|
Ok(Ok(_)) => continue,
|
||||||
_ => break,
|
_ => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
saw_removed,
|
saw_dead,
|
||||||
"expected SessionEvent::Removed for dead session"
|
"expected a SessionUpdated marking the session dead"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dismissing it is what finally releases the artifacts.
|
||||||
|
fx.manager.kill(s.id).await.expect("dismiss");
|
||||||
|
assert!(!fx.manager.has(s.id).await);
|
||||||
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||||
|
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
|
||||||
|
/// loses its worktree on kill — the guard, not the flag, protects work.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
let wt_path = repo.path().join("worktrees").join("feat-keep");
|
||||||
|
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: repo.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: repo.path().to_path_buf(),
|
||||||
|
branch: "feat/keep".into(),
|
||||||
|
cleanup_on_kill: false,
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
assert!(wt_path.exists());
|
||||||
|
|
||||||
|
fx.manager.kill(s.id).await.expect("kill");
|
||||||
|
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
|
||||||
|
/// left on disk where the operator can find it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
let wt_path = repo.path().join("worktrees").join("feat-guarded");
|
||||||
|
|
||||||
|
let s = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: repo.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: repo.path().to_path_buf(),
|
||||||
|
branch: "feat/guarded".into(),
|
||||||
|
cleanup_on_kill: true,
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
|
||||||
|
fx.manager.kill(s.id).await.expect("kill");
|
||||||
|
assert!(
|
||||||
|
wt_path.exists(),
|
||||||
|
"dirty worktree must be left on disk, not destroyed"
|
||||||
|
);
|
||||||
|
assert!(fx.manager.snapshot().await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn liveness_watcher_leaves_running_sessions_alone() {
|
async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||||
let fx = DaemonFixture::new("laptop");
|
let fx = DaemonFixture::new();
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let s = fx
|
let s = fx
|
||||||
.manager
|
.manager
|
||||||
.create(CreateSessionRequest {
|
.create(CreateSessionRequest {
|
||||||
daemon_id: "laptop".into(),
|
|
||||||
cwd: cwd.path().to_path_buf(),
|
cwd: cwd.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ use serde_json::Value;
|
|||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn sample(id: Uuid, daemon_id: &str) -> Session {
|
fn sample(id: Uuid) -> Session {
|
||||||
Session {
|
Session {
|
||||||
id,
|
id,
|
||||||
daemon_id: daemon_id.to_string(),
|
|
||||||
cwd: PathBuf::from("/tmp/work"),
|
cwd: PathBuf::from("/tmp/work"),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
env: HashMap::from([("FOO".into(), "bar".into())]),
|
env: HashMap::from([("FOO".into(), "bar".into())]),
|
||||||
@@ -36,7 +35,7 @@ fn write_then_read_round_trip() {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let store = SidecarStore::new(dir.path());
|
let store = SidecarStore::new(dir.path());
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let s = sample(id, "laptop");
|
let s = sample(id);
|
||||||
|
|
||||||
store.write(&s).expect("write");
|
store.write(&s).expect("write");
|
||||||
let loaded = store.read(id).expect("read");
|
let loaded = store.read(id).expect("read");
|
||||||
@@ -59,7 +58,7 @@ fn delete_removes_file_and_is_idempotent() {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let store = SidecarStore::new(dir.path());
|
let store = SidecarStore::new(dir.path());
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let s = sample(id, "laptop");
|
let s = sample(id);
|
||||||
|
|
||||||
store.write(&s).unwrap();
|
store.write(&s).unwrap();
|
||||||
assert!(store.path_for(id).exists());
|
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 dir = TempDir::new().unwrap();
|
||||||
let store = SidecarStore::new(dir.path());
|
let store = SidecarStore::new(dir.path());
|
||||||
|
|
||||||
let s1 = sample(Uuid::new_v4(), "laptop");
|
let s1 = sample(Uuid::new_v4());
|
||||||
let s2 = sample(Uuid::new_v4(), "dev-vm");
|
let s2 = sample(Uuid::new_v4());
|
||||||
store.write(&s1).unwrap();
|
store.write(&s1).unwrap();
|
||||||
store.write(&s2).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();
|
std::fs::write(dir.path().join("broken.json"), b"{not json}").unwrap();
|
||||||
|
|
||||||
let mut loaded = store.load_all().expect("load_all");
|
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];
|
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);
|
assert_eq!(loaded, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +103,7 @@ fn write_creates_parent_directory() {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let nested = dir.path().join("a/b/c");
|
let nested = dir.path().join("a/b/c");
|
||||||
let store = SidecarStore::new(&nested);
|
let store = SidecarStore::new(&nested);
|
||||||
let s = sample(Uuid::new_v4(), "laptop");
|
let s = sample(Uuid::new_v4());
|
||||||
store.write(&s).expect("write");
|
store.write(&s).expect("write");
|
||||||
assert!(nested.exists());
|
assert!(nested.exists());
|
||||||
assert!(store.path_for(s.id).exists());
|
assert!(store.path_for(s.id).exists());
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ fn add_creates_worktree_on_new_branch() {
|
|||||||
init_repo(&repo);
|
init_repo(&repo);
|
||||||
|
|
||||||
let wt_root = TempDir::new().unwrap();
|
let wt_root = TempDir::new().unwrap();
|
||||||
let mgr = WorktreeManager::new(wt_root.path());
|
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||||
|
|
||||||
let path = mgr.add(&repo, "feat/example").expect("add worktree");
|
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
assert!(path.join("README").exists());
|
assert!(path.join("README").exists());
|
||||||
assert!(path.starts_with(wt_root.path()));
|
assert!(path.starts_with(wt_root.path()));
|
||||||
@@ -42,6 +42,63 @@ fn add_creates_worktree_on_new_branch() {
|
|||||||
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
|
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_places_worktree_inside_the_base_repo_by_default() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
// No override: the worktree must land where the repository keeps its
|
||||||
|
// own worktrees (#56), not under some daemon state dir.
|
||||||
|
let mgr = WorktreeManager::new(None);
|
||||||
|
|
||||||
|
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||||
|
assert_eq!(path, repo.join("worktrees").join("feat-example"));
|
||||||
|
assert!(path.exists());
|
||||||
|
assert!(path.join("README").exists());
|
||||||
|
|
||||||
|
let branches = worktree_branches(&repo);
|
||||||
|
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worktree_root_override_redirects_placement() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
let wt_root = TempDir::new().unwrap();
|
||||||
|
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||||
|
|
||||||
|
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||||
|
assert_eq!(path, wt_root.path().join("feat-example"));
|
||||||
|
assert!(path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_directory_at_target_path_is_an_error_carrying_the_path() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
// A predictable path means collisions are possible; they must be
|
||||||
|
// reported, never silently relocated.
|
||||||
|
std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap();
|
||||||
|
|
||||||
|
let mgr = WorktreeManager::new(None);
|
||||||
|
let err = mgr.add_new(&repo, "feat/collide").unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(
|
||||||
|
msg.contains(
|
||||||
|
repo.join("worktrees")
|
||||||
|
.join("feat-collide")
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
),
|
||||||
|
"{msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_twice_generates_distinct_paths() {
|
fn add_twice_generates_distinct_paths() {
|
||||||
let repo_dir = TempDir::new().unwrap();
|
let repo_dir = TempDir::new().unwrap();
|
||||||
@@ -49,10 +106,10 @@ fn add_twice_generates_distinct_paths() {
|
|||||||
init_repo(&repo);
|
init_repo(&repo);
|
||||||
|
|
||||||
let wt_root = TempDir::new().unwrap();
|
let wt_root = TempDir::new().unwrap();
|
||||||
let mgr = WorktreeManager::new(wt_root.path());
|
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||||
|
|
||||||
let p1 = mgr.add(&repo, "feat/a").expect("add a");
|
let p1 = mgr.add_new(&repo, "feat/a").expect("add a");
|
||||||
let p2 = mgr.add(&repo, "feat/b").expect("add b");
|
let p2 = mgr.add_new(&repo, "feat/b").expect("add b");
|
||||||
assert_ne!(p1, p2);
|
assert_ne!(p1, p2);
|
||||||
assert!(p1.exists());
|
assert!(p1.exists());
|
||||||
assert!(p2.exists());
|
assert!(p2.exists());
|
||||||
@@ -65,9 +122,9 @@ fn remove_prunes_worktree() {
|
|||||||
init_repo(&repo);
|
init_repo(&repo);
|
||||||
|
|
||||||
let wt_root = TempDir::new().unwrap();
|
let wt_root = TempDir::new().unwrap();
|
||||||
let mgr = WorktreeManager::new(wt_root.path());
|
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||||
|
|
||||||
let path = mgr.add(&repo, "feat/gone").expect("add");
|
let path = mgr.add_new(&repo, "feat/gone").expect("add");
|
||||||
assert!(path.exists());
|
assert!(path.exists());
|
||||||
|
|
||||||
mgr.remove(&repo, &path).expect("remove");
|
mgr.remove(&repo, &path).expect("remove");
|
||||||
@@ -79,6 +136,104 @@ fn remove_prunes_worktree() {
|
|||||||
assert_eq!(branches, vec!["main".to_string()]);
|
assert_eq!(branches, vec!["main".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_existing_checks_out_branch_without_creating_it() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
// Pre-create the branch with its own commit so we can tell a checkout
|
||||||
|
// from an accidental `-b`.
|
||||||
|
let created = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["checkout", "-q", "-b", "feat/dup"])
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(created.success());
|
||||||
|
std::fs::write(repo.join("FEATURE"), b"dup\n").unwrap();
|
||||||
|
let staged = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["add", "."])
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(staged.success());
|
||||||
|
let committed = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["commit", "-q", "-am", "wip"])
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(committed.success());
|
||||||
|
let back = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["checkout", "-q", "main"])
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(back.success());
|
||||||
|
|
||||||
|
let wt_root = TempDir::new().unwrap();
|
||||||
|
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||||
|
|
||||||
|
let path = mgr.add_existing(&repo, "feat/dup").expect("add existing");
|
||||||
|
assert!(path.exists());
|
||||||
|
assert!(path.join("README").exists());
|
||||||
|
|
||||||
|
// The branch set is unchanged: `feat/dup` was checked out, not recreated.
|
||||||
|
let branches = worktree_branches(&repo);
|
||||||
|
let mut sorted = branches.clone();
|
||||||
|
sorted.sort();
|
||||||
|
assert_eq!(
|
||||||
|
sorted,
|
||||||
|
vec!["feat/dup".to_string(), "main".to_string()],
|
||||||
|
"{branches:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_refuses_worktree_with_uncommitted_changes() {
|
||||||
|
let repo_dir = TempDir::new().unwrap();
|
||||||
|
let repo = repo_dir.path().to_path_buf();
|
||||||
|
init_repo(&repo);
|
||||||
|
|
||||||
|
let mgr = WorktreeManager::new(None);
|
||||||
|
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
|
||||||
|
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
|
||||||
|
mgr.remove(&repo, &path)
|
||||||
|
.expect_err("dirty worktree must be refused");
|
||||||
|
assert!(path.exists(), "guarded worktree must stay on disk");
|
||||||
|
|
||||||
|
// Committing the change doesn't help while it is the only copy…
|
||||||
|
run_git(&path, &["add", "."]);
|
||||||
|
run_git(&path, &["commit", "-q", "-m", "unsaved"]);
|
||||||
|
|
||||||
|
mgr.remove(&repo, &path)
|
||||||
|
.expect_err("unique commit must be refused");
|
||||||
|
assert!(path.exists());
|
||||||
|
|
||||||
|
// …but once the commit is reachable from main, removal is safe.
|
||||||
|
run_git(&repo, &["merge", "-q", "--no-edit", "feat/dirty"]);
|
||||||
|
mgr.remove(&repo, &path).expect("merged worktree can go");
|
||||||
|
assert!(!path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_git(dir: &std::path::Path, args: &[&str]) {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(dir)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("git spawn");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"git {args:?} failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_fails_when_branch_already_exists() {
|
fn add_fails_when_branch_already_exists() {
|
||||||
let repo_dir = TempDir::new().unwrap();
|
let repo_dir = TempDir::new().unwrap();
|
||||||
@@ -95,9 +250,9 @@ fn add_fails_when_branch_already_exists() {
|
|||||||
assert!(status.success());
|
assert!(status.success());
|
||||||
|
|
||||||
let wt_root = TempDir::new().unwrap();
|
let wt_root = TempDir::new().unwrap();
|
||||||
let mgr = WorktreeManager::new(wt_root.path());
|
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||||
|
|
||||||
let err = mgr.add(&repo, "feat/dup").unwrap_err();
|
let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
|
||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
assert!(msg.contains("git exited"), "{msg}");
|
assert!(msg.contains("git exited"), "{msg}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
publish.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]
|
[lib]
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
@@ -13,8 +13,8 @@ path = "src/lib.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
maestro-protocol.workspace = true
|
maestro-protocol = { workspace = true, features = ["client"] }
|
||||||
reqwest.workspace = true
|
maestro-testing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
//! Thin HTTP+WS client used by tests to drive the registry.
|
//! Thin test harness over [`maestro_protocol::DaemonClient`].
|
||||||
//!
|
//!
|
||||||
//! Cookies are managed automatically by `reqwest`'s built-in cookie store.
|
//! HTTP calls delegate to the shared client so the harness exercises the same
|
||||||
|
//! request building, timeouts and error decoding the CLI and TUI use. Only the
|
||||||
|
//! WebSocket helpers are test-specific: they hand back a raw stream, or a
|
||||||
|
//! handle that accumulates events in the background.
|
||||||
|
|
||||||
#![allow(clippy::result_large_err)]
|
#![allow(clippy::result_large_err)]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_protocol::{ConnectInfo, CreateSessionRequest, DaemonInfo, RegistryEvent, Session};
|
use maestro_protocol::{CatalogEvent, CreateSessionRequest, DaemonClient, Session};
|
||||||
use reqwest::Client as HttpClient;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||||
@@ -18,137 +20,64 @@ use uuid::Uuid;
|
|||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub enum ClientError {
|
pub enum ClientError {
|
||||||
#[error("http: {0}")]
|
#[error(transparent)]
|
||||||
Http(#[from] reqwest::Error),
|
Api(#[from] maestro_protocol::ClientError),
|
||||||
#[error("ws: {0}")]
|
#[error("ws: {0}")]
|
||||||
Ws(#[from] tokio_tungstenite::tungstenite::Error),
|
Ws(#[from] tokio_tungstenite::tungstenite::Error),
|
||||||
#[error("json: {0}")]
|
#[error("json: {0}")]
|
||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
#[error("unexpected status {status}: {body}")]
|
|
||||||
Status { status: u16, body: String },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sticky-cookie HTTP client + a few WS-aware helpers.
|
/// Daemon client plus a few WS-aware helpers, bound to one daemon.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TestClient {
|
pub struct TestClient {
|
||||||
http_base: String,
|
daemon: DaemonClient,
|
||||||
ws_base: String,
|
|
||||||
http: HttpClient,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-request budget. Well below the client default: a hung daemon should fail
|
||||||
|
/// the test, not stall the suite for 30s.
|
||||||
|
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
impl TestClient {
|
impl TestClient {
|
||||||
pub fn new(http_base: &str, ws_base: &str) -> Self {
|
pub fn new(http_base: &str) -> Self {
|
||||||
let http = HttpClient::builder()
|
|
||||||
.cookie_store(true)
|
|
||||||
.timeout(Duration::from_secs(5))
|
|
||||||
.build()
|
|
||||||
.expect("reqwest client");
|
|
||||||
Self {
|
Self {
|
||||||
http_base: http_base.trim_end_matches('/').to_string(),
|
daemon: DaemonClient::with_timeouts(http_base, TEST_TIMEOUT, TEST_TIMEOUT),
|
||||||
ws_base: ws_base.trim_end_matches('/').to_string(),
|
|
||||||
http,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hit any GET endpoint so the registry issues the auth cookie that all
|
/// The shared client underneath, for calls this harness doesn't wrap.
|
||||||
/// subsequent calls reuse.
|
pub fn daemon(&self) -> &DaemonClient {
|
||||||
pub async fn bootstrap(&self) -> Result<(), ClientError> {
|
&self.daemon
|
||||||
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> {
|
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
||||||
let r = self
|
Ok(self.daemon.list_sessions().await?)
|
||||||
.http
|
|
||||||
.get(format!("{}/api/sessions", self.http_base))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
check_ok(&r)?;
|
|
||||||
Ok(r.json().await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn spawn_session(&self, req: &CreateSessionRequest) -> Result<Session, ClientError> {
|
pub async fn spawn_session(&self, req: &CreateSessionRequest) -> Result<Session, ClientError> {
|
||||||
let r = self
|
Ok(self.daemon.create_session(req).await?)
|
||||||
.http
|
|
||||||
.post(format!("{}/api/sessions", self.http_base))
|
|
||||||
.json(req)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let status = r.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
let body = r.text().await.unwrap_or_default();
|
|
||||||
return Err(ClientError::Status {
|
|
||||||
status: status.as_u16(),
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(r.json().await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
|
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
|
||||||
let r = self
|
Ok(self.daemon.kill_session(id).await?)
|
||||||
.http
|
|
||||||
.delete(format!("{}/api/sessions/{id}", self.http_base))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
check_ok(&r)?;
|
|
||||||
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
|
/// Connect to the daemon's per-session WS and return the stream. The
|
||||||
/// caller drives the conversation.
|
/// caller drives the conversation.
|
||||||
pub async fn attach(
|
pub async fn attach(
|
||||||
&self,
|
&self,
|
||||||
info: &ConnectInfo,
|
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> Result<WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>, ClientError> {
|
) -> Result<WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>, ClientError> {
|
||||||
let url = format!(
|
let request = self.daemon.attach_url(id)?.into_client_request()?;
|
||||||
"{}/sessions/{id}/stream?token={}",
|
|
||||||
info.daemon_url.trim_end_matches('/'),
|
|
||||||
info.jwt
|
|
||||||
);
|
|
||||||
let request = url.into_client_request()?;
|
|
||||||
let (ws, _resp) = connect_async(request).await?;
|
let (ws, _resp) = connect_async(request).await?;
|
||||||
Ok(ws)
|
Ok(ws)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to the registry's `/api/events` WS. Returns a tokio task
|
/// Subscribe to the daemon's `/api/events` WS. Returns a handle whose
|
||||||
/// handle that drives the WS and pushes parsed events into an Arc<Mutex<Vec>>.
|
/// background task pushes parsed events into a shared vector.
|
||||||
pub async fn subscribe_events(&self) -> EventStream {
|
pub async fn subscribe_events(&self) -> EventStream {
|
||||||
// Forward the latest cookie value with the upgrade — `reqwest` and
|
let url = self.daemon.events_url().expect("events url");
|
||||||
// `tungstenite` don't share cookie jars, so we copy the cookie out by
|
let request = url.into_client_request().expect("ws req");
|
||||||
// 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 (ws, _resp) = connect_async(request).await.expect("events ws");
|
let (ws, _resp) = connect_async(request).await.expect("events ws");
|
||||||
|
|
||||||
let events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
let events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||||
@@ -158,7 +87,7 @@ impl TestClient {
|
|||||||
while let Some(msg) = stream.next().await {
|
while let Some(msg) = stream.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(WsMessage::Text(t)) => {
|
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);
|
stream_events.lock().await.push(ev);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,56 +99,17 @@ impl TestClient {
|
|||||||
});
|
});
|
||||||
EventStream { events, task }
|
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> {
|
|
||||||
let status = r.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(ClientError::Status {
|
|
||||||
status: status.as_u16(),
|
|
||||||
body: format!("HTTP {status}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle to a background task draining the events WS into a vector.
|
/// Handle to a background task draining the events WS into a vector.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct EventStream {
|
pub struct EventStream {
|
||||||
events: Arc<tokio::sync::Mutex<Vec<RegistryEvent>>>,
|
events: Arc<tokio::sync::Mutex<Vec<CatalogEvent>>>,
|
||||||
task: tokio::task::JoinHandle<()>,
|
task: tokio::task::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventStream {
|
impl EventStream {
|
||||||
pub async fn snapshot(&self) -> Vec<RegistryEvent> {
|
pub async fn snapshot(&self) -> Vec<CatalogEvent> {
|
||||||
self.events.lock().await.clone()
|
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::net::{Ipv4Addr, SocketAddr};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -10,68 +10,51 @@ use tempfile::TempDir;
|
|||||||
use crate::build::workspace_target_bin;
|
use crate::build::workspace_target_bin;
|
||||||
use crate::util::{free_port, wait_for_async};
|
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
|
/// `home` and `tmux_tmpdir` are kept as `TempDir`s so they outlive the process
|
||||||
/// outlive the process and get cleaned up automatically.
|
/// and get cleaned up automatically. State lives under `home` via
|
||||||
|
/// `XDG_STATE_HOME`.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Daemon {
|
pub struct Daemon {
|
||||||
pub daemon_id: String,
|
|
||||||
pub bind_addr: SocketAddr,
|
pub bind_addr: SocketAddr,
|
||||||
pub daemon_url: String,
|
|
||||||
child: Option<Child>,
|
child: Option<Child>,
|
||||||
home: TempDir,
|
home: TempDir,
|
||||||
state: TempDir,
|
|
||||||
tmux: 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 {
|
impl Daemon {
|
||||||
/// Spawn a fresh daemon pointing at `registry_base_url` (the registry's
|
/// Spawn a fresh daemon on a free localhost port.
|
||||||
/// HTTP base, e.g. `http://127.0.0.1:1234`). The daemon derives its WS
|
pub async fn spawn() -> Self {
|
||||||
/// endpoint internally via `derive_daemon_ws_url`.
|
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
|
||||||
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 with NAP-247 options (perimeter advertisement).
|
/// Spawn on a caller-supplied bind address.
|
||||||
pub async fn spawn_with(
|
pub async fn spawn_at(bind: SocketAddr) -> Self {
|
||||||
daemon_id: &str,
|
let home = TempDir::new().expect("home tmpdir");
|
||||||
registry_base_url: &str,
|
let tmux = TempDir::new().expect("tmux tmpdir");
|
||||||
token: &str,
|
|
||||||
opts: DaemonOptions,
|
let child = launch(bind, home.path(), tmux.path());
|
||||||
) -> Self {
|
let me = Self {
|
||||||
let port = free_port();
|
bind_addr: bind,
|
||||||
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
child: Some(child),
|
||||||
Self::spawn_with_port_and_opts(daemon_id, registry_base_url, token, bind, opts).await
|
home,
|
||||||
|
tmux,
|
||||||
|
};
|
||||||
|
me.wait_until_ready().await;
|
||||||
|
me
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn on a caller-supplied bind address. Used by NAP-247 broker tests
|
/// HTTP base URL of this daemon's API.
|
||||||
/// where the daemon's advertised `private_addr` must equal the actual
|
pub fn http_base(&self) -> String {
|
||||||
/// bind address so the broker can dial it back.
|
format!("http://{}", self.bind_addr)
|
||||||
pub async fn spawn_at(
|
}
|
||||||
daemon_id: &str,
|
|
||||||
registry_base_url: &str,
|
/// Shared client bound to this daemon, with the harness's tighter budget.
|
||||||
token: &str,
|
pub fn client(&self) -> maestro_protocol::DaemonClient {
|
||||||
bind: SocketAddr,
|
crate::client::TestClient::new(&self.http_base())
|
||||||
opts: DaemonOptions,
|
.daemon()
|
||||||
) -> Self {
|
.clone()
|
||||||
Self::spawn_with_port_and_opts(daemon_id, registry_base_url, token, bind, opts).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the underlying process, but keep its state dirs around so we can
|
/// Stop the underlying process, but keep its state dirs around so we can
|
||||||
@@ -85,70 +68,18 @@ impl Daemon {
|
|||||||
|
|
||||||
/// Re-spawn the daemon on the same port + state. The previous tmux
|
/// Re-spawn the daemon on the same port + state. The previous tmux
|
||||||
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
|
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
|
||||||
/// its sessions. Restarts inherit the original options via the on-disk
|
/// its sessions.
|
||||||
/// config file written at `spawn_with_port_and_opts` time.
|
|
||||||
pub async fn restart(&mut self) {
|
pub async fn restart(&mut self) {
|
||||||
self.stop();
|
self.stop();
|
||||||
let child = launch(
|
self.child = Some(launch(self.bind_addr, self.home.path(), self.tmux.path()));
|
||||||
&self.daemon_id,
|
self.wait_until_ready().await;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_with_port_and_opts(
|
async fn wait_until_ready(&self) {
|
||||||
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) {
|
|
||||||
let addr = self.bind_addr;
|
let addr = self.bind_addr;
|
||||||
wait_for_async(
|
wait_for_async("daemon api port", Duration::from_secs(5), || async move {
|
||||||
"daemon attach port",
|
tokio::net::TcpStream::connect(addr).await.is_ok()
|
||||||
Duration::from_secs(5),
|
})
|
||||||
|| async move { tokio::net::TcpStream::connect(addr).await.is_ok() },
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +89,7 @@ impl Daemon {
|
|||||||
|
|
||||||
pub fn state_dir(&self) -> PathBuf {
|
pub fn state_dir(&self) -> PathBuf {
|
||||||
// Matches maestro_daemon::default_state_dir which uses XDG_STATE_HOME.
|
// 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 +102,7 @@ impl Drop for Daemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
fn launch(bind: SocketAddr, home: &std::path::Path, tmux_tmpdir: &std::path::Path) -> Child {
|
||||||
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 {
|
|
||||||
let bin = workspace_target_bin("maestro", "maestro");
|
let bin = workspace_target_bin("maestro", "maestro");
|
||||||
let mut cmd = Command::new(&bin);
|
let mut cmd = Command::new(&bin);
|
||||||
cmd.arg("daemon");
|
cmd.arg("daemon");
|
||||||
@@ -195,21 +115,11 @@ fn launch(
|
|||||||
.env("XDG_CONFIG_HOME", home.join(".config"))
|
.env("XDG_CONFIG_HOME", home.join(".config"))
|
||||||
.env("XDG_STATE_HOME", home.join(".local/state"))
|
.env("XDG_STATE_HOME", home.join(".local/state"))
|
||||||
.env("TMUX_TMPDIR", tmux_tmpdir)
|
.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_BIND_ADDR", bind.to_string())
|
||||||
.env("MAESTRO_DAEMON_URL", daemon_url)
|
|
||||||
.env("RUST_LOG", "warn")
|
.env("RUST_LOG", "warn")
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::inherit())
|
.stderr(Stdio::inherit())
|
||||||
.arg("run");
|
.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()
|
cmd.spawn()
|
||||||
.unwrap_or_else(|e| panic!("spawn {} failed: {e}", bin.display()))
|
.unwrap_or_else(|e| panic!("spawn {} failed: {e}", bin.display()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Cross-process integration test harness for Maestro.
|
//! Cross-process integration test harness for Maestro.
|
||||||
//!
|
//!
|
||||||
//! Each test spawns a real `maestro-registry` and one or more `maestrod`
|
//! Each test spawns a real `maestro daemon` subprocess, isolates it in temp
|
||||||
//! subprocesses, isolates them in temp dirs (`$HOME`, `TMUX_TMPDIR`, state
|
//! dirs (`$HOME`, `TMUX_TMPDIR`, state dir), and drives it through its public
|
||||||
//! dir), and drives them through their public HTTP/WS APIs.
|
//! HTTP/WS API.
|
||||||
//!
|
//!
|
||||||
//! See `tests/` for the actual scenarios.
|
//! See `tests/` for the actual scenarios.
|
||||||
|
|
||||||
@@ -11,10 +11,8 @@
|
|||||||
pub mod build;
|
pub mod build;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod daemon;
|
pub mod daemon;
|
||||||
pub mod registry;
|
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use build::workspace_target_bin;
|
pub use build::workspace_target_bin;
|
||||||
pub use client::TestClient;
|
pub use client::TestClient;
|
||||||
pub use daemon::{Daemon, DaemonOptions};
|
pub use daemon::Daemon;
|
||||||
pub use registry::Registry;
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+150
-583
@@ -1,9 +1,8 @@
|
|||||||
//! Cross-process end-to-end tests for the headless Maestro slice.
|
//! Cross-process end-to-end tests for the headless Maestro slice.
|
||||||
//!
|
//!
|
||||||
//! Each test brings up a real `maestro-registry` plus 1-2 real `maestrod`
|
//! Each test brings up a real daemon subprocess, isolated in temp dirs, and
|
||||||
//! subprocesses, isolates them in temp dirs, and drives them through the
|
//! drives it through the public HTTP+WS surface using the same client APIs a
|
||||||
//! public HTTP+WS surface using the same client APIs a real CLI/web client
|
//! real CLI/web client would.
|
||||||
//! would.
|
|
||||||
//!
|
//!
|
||||||
//! These tests require `tmux` and `git` on `$PATH`.
|
//! These tests require `tmux` and `git` on `$PATH`.
|
||||||
|
|
||||||
@@ -15,45 +14,19 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use maestro_e2e::util::wait_for_async;
|
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 maestro_protocol::{CreateSessionRequest, WorktreeSpec};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
|
|
||||||
const TOKEN: &str = "e2e-shared-token";
|
async fn bring_up() -> (Daemon, TestClient) {
|
||||||
|
let daemon = Daemon::spawn().await;
|
||||||
async fn bring_up(daemon_ids: &[&str]) -> (Registry, Vec<Daemon>, TestClient) {
|
let client = TestClient::new(&daemon.http_base());
|
||||||
let registry = Registry::spawn(TOKEN).await;
|
(daemon, client)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_req(daemon_id: &str, cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
||||||
CreateSessionRequest {
|
CreateSessionRequest {
|
||||||
daemon_id: daemon_id.into(),
|
|
||||||
cwd: cwd.to_path_buf(),
|
cwd: cwd.to_path_buf(),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
env: HashMap::new(),
|
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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn lists_sessions_from_both_daemons() {
|
async fn lists_spawned_sessions() {
|
||||||
let (_reg, _daemons, client) = bring_up(&["alpha", "beta"]).await;
|
let (_daemon, client) = bring_up().await;
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let a = client
|
let a = client
|
||||||
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
|
.spawn_session(&create_req(cwd.path(), "sleep 30"))
|
||||||
.await
|
.await
|
||||||
.expect("spawn alpha");
|
.expect("spawn a");
|
||||||
let b = client
|
let b = client
|
||||||
.spawn_session(&create_req("beta", cwd.path(), "sleep 30"))
|
.spawn_session(&create_req(cwd.path(), "sleep 30"))
|
||||||
.await
|
.await
|
||||||
.expect("spawn beta");
|
.expect("spawn b");
|
||||||
|
|
||||||
let sessions = client.list_sessions().await.expect("list");
|
let sessions = client.list_sessions().await.expect("list");
|
||||||
assert!(sessions
|
assert!(sessions.iter().any(|s| s.id == a.id));
|
||||||
.iter()
|
assert!(sessions.iter().any(|s| s.id == b.id));
|
||||||
.any(|s| s.id == a.id && s.daemon_id == "alpha"));
|
|
||||||
assert!(sessions
|
|
||||||
.iter()
|
|
||||||
.any(|s| s.id == b.id && s.daemon_id == "beta"));
|
|
||||||
|
|
||||||
client.kill_session(a.id).await.expect("kill a");
|
client.kill_session(a.id).await.expect("kill a");
|
||||||
client.kill_session(b.id).await.expect("kill b");
|
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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn multi_attach_sees_same_output() {
|
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 cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let s = client
|
let s = client
|
||||||
.spawn_session(&create_req("solo", cwd.path(), "bash --noprofile --norc"))
|
.spawn_session(&create_req(cwd.path(), "bash --noprofile --norc"))
|
||||||
.await
|
.await
|
||||||
.expect("spawn");
|
.expect("spawn");
|
||||||
|
|
||||||
let info = client.connect_info(s.id).await.expect("connect-info");
|
let mut a = client.attach(s.id).await.expect("attach a");
|
||||||
let mut a = client.attach(&info, s.id).await.expect("attach a");
|
let mut b = client.attach(s.id).await.expect("attach b");
|
||||||
let mut b = client.attach(&info, s.id).await.expect("attach b");
|
|
||||||
|
|
||||||
// Drive input through client A; both attachments should observe it via
|
// Drive input through client A; both attachments should observe it via
|
||||||
// the daemon's PTY broadcast.
|
// the daemon's PTY broadcast.
|
||||||
@@ -313,14 +114,13 @@ async fn multi_attach_sees_same_output() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn cleanup_on_kill_removes_worktree() {
|
async fn kill_removes_clean_worktree() {
|
||||||
let (_reg, _daemons, client) = bring_up(&["host"]).await;
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
let base = TempDir::new().unwrap();
|
let base = TempDir::new().unwrap();
|
||||||
init_repo(base.path());
|
init_repo(base.path());
|
||||||
|
|
||||||
let req = CreateSessionRequest {
|
let req = CreateSessionRequest {
|
||||||
daemon_id: "host".into(),
|
|
||||||
cwd: base.path().to_path_buf(),
|
cwd: base.path().to_path_buf(),
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
env: HashMap::new(),
|
env: HashMap::new(),
|
||||||
@@ -329,18 +129,19 @@ async fn cleanup_on_kill_removes_worktree() {
|
|||||||
base_repo: base.path().to_path_buf(),
|
base_repo: base.path().to_path_buf(),
|
||||||
branch: "feature/e2e".into(),
|
branch: "feature/e2e".into(),
|
||||||
cleanup_on_kill: true,
|
cleanup_on_kill: true,
|
||||||
|
create_branch: true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
// The daemon's WorktreeManager puts new worktrees under
|
// Worktrees live inside the base repo at `<repo>/worktrees/<branch>`
|
||||||
// `<state_dir>/worktrees/<branch>`. We can't peek the daemon's state dir
|
// (#56), so `git worktree list` must show the predictable in-repo path.
|
||||||
// directly from here, but we can verify by asking git: a worktree must
|
|
||||||
// exist that lives outside the original repo.
|
|
||||||
let worktrees = git_worktree_list(base.path());
|
let worktrees = git_worktree_list(base.path());
|
||||||
assert!(
|
assert!(
|
||||||
worktrees.iter().any(|w| w.contains("feature/e2e")),
|
worktrees
|
||||||
"expected a worktree for feature/e2e branch, got: {worktrees:?}"
|
.iter()
|
||||||
|
.any(|w| w.contains("worktrees/feature-e2e")),
|
||||||
|
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
client.kill_session(s.id).await.expect("kill");
|
client.kill_session(s.id).await.expect("kill");
|
||||||
@@ -360,54 +161,101 @@ async fn cleanup_on_kill_removes_worktree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The guard (#56): killing a session whose worktree holds uncommitted
|
||||||
|
/// changes must leave the tree on disk — cleanup is unconditional, so this
|
||||||
|
/// guard is what keeps a stray kill from destroying work.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn kill_spares_worktree_with_uncommitted_changes() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
let wt_path = base.path().join("worktrees").join("feature-unsaved");
|
||||||
|
|
||||||
|
let req = CreateSessionRequest {
|
||||||
|
cwd: base.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: base.path().to_path_buf(),
|
||||||
|
branch: "feature/unsaved".into(),
|
||||||
|
cleanup_on_kill: true,
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||||
|
assert!(wt_path.exists());
|
||||||
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||||
|
|
||||||
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
|
||||||
|
// The session is gone but the dirty worktree stays findable on disk.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
let sessions = client.list_sessions().await.unwrap_or_default();
|
||||||
|
if sessions.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
wt_path.exists(),
|
||||||
|
"guarded worktree must be left on disk after kill"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn spawn_on_existing_branch_checks_it_out() {
|
||||||
|
let (_daemon, client) = bring_up().await;
|
||||||
|
|
||||||
|
let base = TempDir::new().unwrap();
|
||||||
|
init_repo(base.path());
|
||||||
|
run_git(&["branch", "feature/existing"], base.path());
|
||||||
|
|
||||||
|
let req = CreateSessionRequest {
|
||||||
|
cwd: base.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: base.path().to_path_buf(),
|
||||||
|
branch: "feature/existing".into(),
|
||||||
|
cleanup_on_kill: true,
|
||||||
|
create_branch: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let s = client
|
||||||
|
.spawn_session(&req)
|
||||||
|
.await
|
||||||
|
.expect("spawn on existing branch");
|
||||||
|
let worktrees = git_worktree_list(base.path());
|
||||||
|
assert!(
|
||||||
|
worktrees.iter().any(|w| w.contains("feature/existing")),
|
||||||
|
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
client.kill_session(s.id).await.expect("kill");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn daemon_restart_recovers_session() {
|
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 cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let s = client
|
let s = client
|
||||||
.spawn_session(&create_req("restarter", cwd.path(), "sleep 300"))
|
.spawn_session(&create_req(cwd.path(), "sleep 300"))
|
||||||
.await
|
.await
|
||||||
.expect("spawn");
|
.expect("spawn");
|
||||||
assert_eq!(s.daemon_id, "restarter");
|
|
||||||
|
|
||||||
// Stop the daemon and wait for the registry to notice the disconnect.
|
// Restart against the same tmux server + state dir: the daemon must adopt
|
||||||
// The catalog drops sessions for a daemon that's no longer connected.
|
// the still-running tmux session on boot.
|
||||||
daemons[0].stop();
|
daemon.restart().await;
|
||||||
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;
|
|
||||||
|
|
||||||
wait_for_async(
|
wait_for_async(
|
||||||
"session reappears in catalog",
|
"session reappears after restart",
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(10),
|
||||||
|| {
|
|| {
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
@@ -428,7 +276,7 @@ async fn daemon_restart_recovers_session() {
|
|||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn concurrent_spawns_each_get_unique_ids() {
|
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 cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
@@ -437,7 +285,7 @@ async fn concurrent_spawns_each_get_unique_ids() {
|
|||||||
let path = cwd.path().to_path_buf();
|
let path = cwd.path().to_path_buf();
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
client
|
client
|
||||||
.spawn_session(&create_req("parallel", &path, "sleep 30"))
|
.spawn_session(&create_req(&path, "sleep 30"))
|
||||||
.await
|
.await
|
||||||
.expect("spawn")
|
.expect("spawn")
|
||||||
}));
|
}));
|
||||||
@@ -461,8 +309,7 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
|
|||||||
// the daemon's recent-dirs endpoint surfaces on the spawn form's next open
|
// 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
|
// (#27's recents picker), and gitroot infers the worktree base_repo from the
|
||||||
// same dir. Exercised end to end against real tmux + git.
|
// same dir. Exercised end to end against real tmux + git.
|
||||||
let (_reg, daemons, client) = bring_up(&["host"]).await;
|
let (daemon, client) = bring_up().await;
|
||||||
let daemon_base = format!("http://{}", daemons[0].bind_addr);
|
|
||||||
|
|
||||||
// A git repo as the working dir, so gitroot has a repo to infer.
|
// A git repo as the working dir, so gitroot has a repo to infer.
|
||||||
let repo = TempDir::new().unwrap();
|
let repo = TempDir::new().unwrap();
|
||||||
@@ -471,7 +318,7 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
|
|||||||
|
|
||||||
// Spawn in that dir (the command stands in for a preset tile selection).
|
// Spawn in that dir (the command stands in for a preset tile selection).
|
||||||
let s = client
|
let s = client
|
||||||
.spawn_session(&create_req("host", repo.path(), "sleep 30"))
|
.spawn_session(&create_req(repo.path(), "sleep 30"))
|
||||||
.await
|
.await
|
||||||
.expect("spawn");
|
.expect("spawn");
|
||||||
|
|
||||||
@@ -482,16 +329,11 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
|
|||||||
"recent-dirs surfaces the spawned cwd",
|
"recent-dirs surfaces the spawned cwd",
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
|| {
|
|| {
|
||||||
let url = format!("{daemon_base}/api/daemons/host/recent-dirs");
|
let client = daemon.client();
|
||||||
let want = cwd.clone();
|
let want = cwd.clone();
|
||||||
async move {
|
async move {
|
||||||
let resp = match reqwest::get(&url).await {
|
let Ok(recents) = client.recent_dirs().await else {
|
||||||
Ok(r) => r,
|
return false;
|
||||||
Err(_) => return false,
|
|
||||||
};
|
|
||||||
let recents: maestro_protocol::RecentDirs = match resp.json().await {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(_) => return false,
|
|
||||||
};
|
};
|
||||||
recents
|
recents
|
||||||
.dirs
|
.dirs
|
||||||
@@ -504,15 +346,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
|
// gitroot infers the worktree base_repo from the same dir (#25/#26): the dir
|
||||||
// is itself the repo toplevel (it holds `.git`).
|
// is itself the repo toplevel (it holds `.git`).
|
||||||
let gr: maestro_protocol::GitRoot = reqwest::Client::new()
|
let gr = daemon.client().git_root(&cwd).await.expect("gitroot");
|
||||||
.get(format!("{daemon_base}/api/daemons/host/gitroot"))
|
|
||||||
.query(&[("path", cwd.as_str())])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("gitroot request")
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.expect("gitroot json");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
gr.root.as_deref().map(|p| p.to_string_lossy().to_string()),
|
gr.root.as_deref().map(|p| p.to_string_lossy().to_string()),
|
||||||
Some(cwd.clone()),
|
Some(cwd.clone()),
|
||||||
@@ -530,15 +364,15 @@ async fn activity_reflects_foreground_and_input_blocking() {
|
|||||||
// (the distinction the /proc/<pid>/syscall check buys us). Both report the
|
// (the distinction the /proc/<pid>/syscall check buys us). Both report the
|
||||||
// right foreground. Exercised against real tmux.
|
// right foreground. Exercised against real tmux.
|
||||||
use maestro_protocol::ActivityState;
|
use maestro_protocol::ActivityState;
|
||||||
let (_reg, _daemons, client) = bring_up(&["host"]).await;
|
let (_daemon, client) = bring_up().await;
|
||||||
let cwd = TempDir::new().unwrap();
|
let cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let cat = client
|
let cat = client
|
||||||
.spawn_session(&create_req("host", cwd.path(), "cat"))
|
.spawn_session(&create_req(cwd.path(), "cat"))
|
||||||
.await
|
.await
|
||||||
.expect("spawn cat");
|
.expect("spawn cat");
|
||||||
let slp = client
|
let slp = client
|
||||||
.spawn_session(&create_req("host", cwd.path(), "sleep 300"))
|
.spawn_session(&create_req(cwd.path(), "sleep 300"))
|
||||||
.await
|
.await
|
||||||
.expect("spawn sleep");
|
.expect("spawn sleep");
|
||||||
|
|
||||||
@@ -621,244 +455,14 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
|
|||||||
.collect()
|
.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")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
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.
|
// 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 cwd = TempDir::new().unwrap();
|
||||||
|
|
||||||
let alpha_addr = daemons[0].bind_addr;
|
let ws_url = daemon.client().events_url().unwrap();
|
||||||
let ws_url = format!("ws://{alpha_addr}/api/events");
|
|
||||||
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url)
|
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url)
|
||||||
.await
|
.await
|
||||||
.expect("events WS handshake");
|
.expect("events WS handshake");
|
||||||
@@ -866,23 +470,22 @@ async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
|||||||
// First frame must be a Snapshot (the WS sends it eagerly on connect).
|
// First frame must be a Snapshot (the WS sends it eagerly on connect).
|
||||||
let snapshot = next_event(&mut ws).await;
|
let snapshot = next_event(&mut ws).await;
|
||||||
match &snapshot {
|
match &snapshot {
|
||||||
maestro_protocol::RegistryEvent::Snapshot { daemons, sessions } => {
|
maestro_protocol::CatalogEvent::Snapshot { sessions } => {
|
||||||
assert!(daemons.iter().any(|d| d.daemon_id == "alpha"));
|
|
||||||
assert!(sessions.is_empty());
|
assert!(sessions.is_empty());
|
||||||
}
|
}
|
||||||
other => panic!("expected initial Snapshot, got {other:?}"),
|
other => panic!("expected initial Snapshot, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
let session = client
|
let session = client
|
||||||
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
|
.spawn_session(&create_req(cwd.path(), "sleep 30"))
|
||||||
.await
|
.await
|
||||||
.expect("spawn alpha");
|
.expect("spawn");
|
||||||
|
|
||||||
// SessionAdded must show up on the WS within a reasonable window.
|
// SessionAdded must show up on the WS within a reasonable window.
|
||||||
let saw_added = tokio::time::timeout(Duration::from_secs(5), async {
|
let saw_added = tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
loop {
|
loop {
|
||||||
match next_event(&mut ws).await {
|
match next_event(&mut ws).await {
|
||||||
maestro_protocol::RegistryEvent::SessionAdded { session: s }
|
maestro_protocol::CatalogEvent::SessionAdded { session: s }
|
||||||
if s.id == session.id =>
|
if s.id == session.id =>
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -902,7 +505,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.
|
/// Run the real `maestro` binary with an isolated env and capture its output.
|
||||||
@@ -921,18 +524,14 @@ fn run_cli(args: &[&str], envs: &[(&str, &str)]) -> std::process::Output {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn cli_list_reads_from_home_daemon() {
|
async fn cli_list_reads_from_daemon() {
|
||||||
// Broker path: point the CLI at the daemon's local API and DON'T configure
|
// The subject is the shipped `maestro` binary — arg parsing, config
|
||||||
// a registry. Success proves the catalog read came from the home daemon —
|
// precedence, output. It needs a daemon to talk to, not tmux, so this runs
|
||||||
// the registry path would have errored with NoRegistryUrl.
|
// against an in-memory one.
|
||||||
let (_reg, daemons, client) = bring_up(&["alpha"]).await;
|
let mock = maestro_testing::MockDaemon::start().await;
|
||||||
let cwd = TempDir::new().unwrap();
|
let id = uuid::Uuid::new_v4();
|
||||||
let session = client
|
mock.add_session(maestro_testing::session(id, "/home/mock/work", "sleep 30"));
|
||||||
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
|
|
||||||
.await
|
|
||||||
.expect("spawn");
|
|
||||||
|
|
||||||
let daemon_url = format!("http://{}", daemons[0].bind_addr);
|
|
||||||
let home = TempDir::new().unwrap();
|
let home = TempDir::new().unwrap();
|
||||||
let cfg_path = home.path().join("config.toml");
|
let cfg_path = home.path().join("config.toml");
|
||||||
let out = run_cli(
|
let out = run_cli(
|
||||||
@@ -940,73 +539,41 @@ async fn cli_list_reads_from_home_daemon() {
|
|||||||
&[
|
&[
|
||||||
("HOME", home.path().to_str().unwrap()),
|
("HOME", home.path().to_str().unwrap()),
|
||||||
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
|
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
|
||||||
("MAESTRO_HOME_DAEMON_URL", &daemon_url),
|
("MAESTRO_DAEMON_URL", &mock.http_base()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
assert!(out.status.success(), "list failed: {stderr}\n{stdout}");
|
assert!(out.status.success(), "list failed: {stderr}\n{stdout}");
|
||||||
assert!(
|
assert!(
|
||||||
stdout.contains(&session.id.to_string()),
|
stdout.contains(&id.to_string()),
|
||||||
"session not listed via home daemon: {stdout}"
|
"session not listed: {stdout}"
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
!stderr.contains("falling back"),
|
|
||||||
"unexpected registry fallback: {stderr}"
|
|
||||||
);
|
|
||||||
|
|
||||||
client.kill_session(session.id).await.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn cli_list_falls_back_to_registry_when_daemon_down() {
|
async fn cli_list_fails_when_daemon_is_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");
|
|
||||||
|
|
||||||
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
|
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 home = TempDir::new().unwrap();
|
||||||
let cfg_path = home.path().join("config.toml");
|
let cfg_path = home.path().join("config.toml");
|
||||||
let out = run_cli(
|
let out = run_cli(
|
||||||
&["list", "--json"],
|
&["list"],
|
||||||
&[
|
&[
|
||||||
("HOME", home.path().to_str().unwrap()),
|
("HOME", home.path().to_str().unwrap()),
|
||||||
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
|
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
|
||||||
("MAESTRO_HOME_DAEMON_URL", &dead_daemon),
|
("MAESTRO_DAEMON_URL", &dead_daemon),
|
||||||
("MAESTRO_REGISTRY_URL", ®istry_url),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
assert!(!out.status.success(), "expected a failure with no daemon");
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_event<S>(ws: &mut S) -> maestro_protocol::RegistryEvent
|
async fn next_event<S>(ws: &mut S) -> maestro_protocol::CatalogEvent
|
||||||
where
|
where
|
||||||
S: StreamExt<Item = Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
S: StreamExt<Item = Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||||
{
|
{
|
||||||
loop {
|
loop {
|
||||||
match ws.next().await {
|
match ws.next().await {
|
||||||
Some(Ok(WsMessage::Text(t))) => {
|
Some(Ok(WsMessage::Text(t))) => return serde_json::from_str(&t).expect("CatalogEvent"),
|
||||||
return serde_json::from_str(&t).expect("RegistryEvent")
|
|
||||||
}
|
|
||||||
Some(Ok(_)) => continue,
|
Some(Ok(_)) => continue,
|
||||||
other => panic!("WS closed waiting for event: {other:?}"),
|
other => panic!("WS closed waiting for event: {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
//! 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
|
|
||||||
//! 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`.
|
|
||||||
//!
|
|
||||||
//! Requires `tmux` and `git` on `$PATH`.
|
|
||||||
|
|
||||||
#![allow(clippy::result_large_err)]
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use maestro_e2e::{Daemon, Registry};
|
|
||||||
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,
|
|
||||||
Fut: std::future::Future<Output = bool>,
|
|
||||||
{
|
|
||||||
let start = Instant::now();
|
|
||||||
loop {
|
|
||||||
if check().await {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if start.elapsed() >= deadline {
|
|
||||||
panic!("timeout waiting for {label}");
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 store = CatalogStore::default();
|
|
||||||
let bg = store.clone();
|
|
||||||
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
|
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
|
|
||||||
.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());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 store = CatalogStore::default();
|
|
||||||
let bg = store.clone();
|
|
||||||
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
|
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
|
|
||||||
.await
|
|
||||||
.expect("initial snapshot");
|
|
||||||
|
|
||||||
let cwd = PathBuf::from(daemon.home());
|
|
||||||
let req = CreateSessionRequest {
|
|
||||||
daemon_id: "konishi".into(),
|
|
||||||
cwd,
|
|
||||||
command: "bash".into(),
|
|
||||||
env: Default::default(),
|
|
||||||
metadata: Default::default(),
|
|
||||||
worktree: None,
|
|
||||||
};
|
|
||||||
let res = reqwest::Client::new()
|
|
||||||
.post(format!("{http_base}/api/sessions"))
|
|
||||||
.json(&req)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("post sessions");
|
|
||||||
assert!(res.status().is_success(), "POST /api/sessions: {res:?}");
|
|
||||||
|
|
||||||
wait_until(
|
|
||||||
"session to surface in catalog",
|
|
||||||
Duration::from_secs(2),
|
|
||||||
|| async { store.snapshot().sessions.len() == 1 },
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
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 store = CatalogStore::default();
|
|
||||||
let bg = store.clone();
|
|
||||||
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
|
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
|
|
||||||
.await
|
|
||||||
.expect("initial snapshot");
|
|
||||||
|
|
||||||
let cwd = PathBuf::from(daemon.home());
|
|
||||||
let req = CreateSessionRequest {
|
|
||||||
daemon_id: "konishi".into(),
|
|
||||||
cwd,
|
|
||||||
command: "bash".into(),
|
|
||||||
env: Default::default(),
|
|
||||||
metadata: Default::default(),
|
|
||||||
worktree: None,
|
|
||||||
};
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let res = client
|
|
||||||
.post(format!("{http_base}/api/sessions"))
|
|
||||||
.json(&req)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(res.status().is_success());
|
|
||||||
|
|
||||||
wait_until("creation", Duration::from_secs(2), || async {
|
|
||||||
store.snapshot().sessions.len() == 1
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let sid = store.snapshot().sessions[0].id;
|
|
||||||
let res = client
|
|
||||||
.delete(format!("{http_base}/api/sessions/{sid}"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(res.status().is_success(), "DELETE /api/sessions: {res:?}");
|
|
||||||
|
|
||||||
wait_until("removal", Duration::from_secs(2), || async {
|
|
||||||
store.snapshot().sessions.is_empty()
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
@@ -44,9 +44,7 @@ impl SystemctlAction {
|
|||||||
/// Build the `cargo install` argument vector.
|
/// Build the `cargo install` argument vector.
|
||||||
///
|
///
|
||||||
/// `features`, when `Some`, restricts the build to an explicit feature set via
|
/// `features`, when `Some`, restricts the build to an explicit feature set via
|
||||||
/// `--no-default-features --features <list>`. The installer uses this to skip
|
/// `--no-default-features --features <list>`.
|
||||||
/// the `registry` feature of the umbrella `maestro` crate: a host runs the CLI
|
|
||||||
/// + daemon (+ TUI); the registry is a separate, centrally-deployed service.
|
|
||||||
pub fn cargo_install_args(
|
pub fn cargo_install_args(
|
||||||
source: &CargoSource,
|
source: &CargoSource,
|
||||||
crate_name: &str,
|
crate_name: &str,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! `maestro-install` — interactive wizard that installs the unified `maestro`
|
//! `maestro-install` — interactive wizard that installs the unified `maestro`
|
||||||
//! binary via `cargo install`, optionally drops a systemd user unit, and
|
//! binary via `cargo install` and optionally drops a systemd user unit.
|
||||||
//! optionally kicks off `maestro daemon configure`.
|
|
||||||
//!
|
//!
|
||||||
//! Intended bootstrap path:
|
//! Intended bootstrap path:
|
||||||
//!
|
//!
|
||||||
@@ -22,13 +21,9 @@ use maestro_installer::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
|
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
|
||||||
/// Unified `maestro` crate. The installer builds it with the host feature set
|
/// Unified `maestro` crate, built with the full host feature set.
|
||||||
/// (CLI + daemon + TUI); the `registry` feature is left out (see
|
|
||||||
/// `MAESTRO_HOST_FEATURES`).
|
|
||||||
const MAESTRO_CRATE: &str = "maestro";
|
const MAESTRO_CRATE: &str = "maestro";
|
||||||
/// Features installed for a host: CLI + per-host daemon + TUI. The `registry`
|
/// Features installed for a host: CLI + per-host daemon + TUI.
|
||||||
/// feature is deliberately omitted — the registry is a separate, centrally-
|
|
||||||
/// deployed service, not something a host install should compile or run.
|
|
||||||
const MAESTRO_HOST_FEATURES: &str = "cli,daemon,tui";
|
const MAESTRO_HOST_FEATURES: &str = "cli,daemon,tui";
|
||||||
const WEB_CRATE: &str = "maestro-web";
|
const WEB_CRATE: &str = "maestro-web";
|
||||||
const SYSTEMD_PROBE: &str = "/run/systemd/system";
|
const SYSTEMD_PROBE: &str = "/run/systemd/system";
|
||||||
@@ -57,7 +52,7 @@ struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_locked: bool,
|
no_locked: bool,
|
||||||
|
|
||||||
/// Don't prompt — accept all defaults (install + systemd + configure).
|
/// Don't prompt — accept all defaults (install + systemd).
|
||||||
#[arg(long, short)]
|
#[arg(long, short)]
|
||||||
yes: bool,
|
yes: bool,
|
||||||
|
|
||||||
@@ -65,10 +60,6 @@ struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_systemd: bool,
|
no_systemd: bool,
|
||||||
|
|
||||||
/// Skip the `maestro daemon configure` step.
|
|
||||||
#[arg(long)]
|
|
||||||
no_configure: bool,
|
|
||||||
|
|
||||||
/// Override the ExecStart path written to the systemd unit.
|
/// Override the ExecStart path written to the systemd unit.
|
||||||
/// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`).
|
/// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`).
|
||||||
#[arg(long, value_name = "PATH")]
|
#[arg(long, value_name = "PATH")]
|
||||||
@@ -107,51 +98,25 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return Ok(());
|
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;
|
let locked = !args.no_locked;
|
||||||
install_crate(&source, MAESTRO_CRATE, locked, Some(MAESTRO_HOST_FEATURES))?;
|
install_crate(&source, MAESTRO_CRATE, locked, Some(MAESTRO_HOST_FEATURES))?;
|
||||||
|
|
||||||
// 2. Offer to run `maestro daemon configure` now (must happen before systemd
|
// 2. systemd integration.
|
||||||
// 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.
|
|
||||||
let systemd_detected = is_systemd_host(Path::new(SYSTEMD_PROBE));
|
let systemd_detected = is_systemd_host(Path::new(SYSTEMD_PROBE));
|
||||||
if args.no_systemd {
|
if args.no_systemd {
|
||||||
eprintln!("\nSkipping systemd step (--no-systemd).");
|
eprintln!("\nSkipping systemd step (--no-systemd).");
|
||||||
} else if !systemd_detected {
|
} else if !systemd_detected {
|
||||||
eprintln!("\nNo systemd detected ({SYSTEMD_PROBE} missing). Skipping unit install.");
|
eprintln!("\nNo systemd detected ({SYSTEMD_PROBE} missing). Skipping unit install.");
|
||||||
} else {
|
} 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.
|
// install and refresh rather than re-prompting, matching the daemon flow.
|
||||||
let web_installed = handle_web(&args, interactive, systemd_detected)?;
|
let web_installed = handle_web(&args, interactive, systemd_detected)?;
|
||||||
|
|
||||||
print_summary(
|
print_summary(systemd_detected && !args.no_systemd, web_installed);
|
||||||
configured,
|
|
||||||
systemd_detected && !args.no_systemd,
|
|
||||||
web_installed,
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +135,9 @@ fn print_banner(source: &CargoSource) {
|
|||||||
eprintln!("maestro installer");
|
eprintln!("maestro installer");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("This wizard will:");
|
eprintln!("This wizard will:");
|
||||||
eprintln!(
|
eprintln!(" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI)");
|
||||||
" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI; registry runs separately)"
|
eprintln!(" 2. (optional) install a systemd user unit and enable it");
|
||||||
);
|
eprintln!(" 3. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
|
||||||
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!();
|
eprintln!();
|
||||||
match source {
|
match source {
|
||||||
CargoSource::Git { url, branch } => {
|
CargoSource::Git { url, branch } => {
|
||||||
@@ -205,65 +167,7 @@ fn install_crate(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maestro_config_path() -> Option<PathBuf> {
|
fn handle_systemd(args: &Args, interactive: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
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>> {
|
|
||||||
let unit_path = user_unit_path().ok_or("could not resolve ~/.config/systemd/user/")?;
|
let unit_path = user_unit_path().ok_or("could not resolve ~/.config/systemd/user/")?;
|
||||||
let exec_start = match &args.exec_start {
|
let exec_start = match &args.exec_start {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
@@ -326,12 +230,6 @@ fn handle_systemd(
|
|||||||
} else {
|
} else {
|
||||||
let want = if !interactive {
|
let want = if !interactive {
|
||||||
false
|
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 {
|
} else {
|
||||||
confirm("Start maestro.service now?", true)?
|
confirm("Start maestro.service now?", true)?
|
||||||
};
|
};
|
||||||
@@ -421,10 +319,10 @@ fn handle_web(
|
|||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"\nOptional: install the browser UI (`maestro-web`)?\n \
|
"\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 \
|
http://127.0.0.1:8080/ by default.\n \
|
||||||
Edit [web].bind_addr in ~/.config/maestro/config.toml \
|
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)?
|
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!();
|
||||||
eprintln!("Done.");
|
eprintln!("Done.");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Next steps:");
|
eprintln!("Next steps:");
|
||||||
if !configured {
|
|
||||||
eprintln!(" • Run `maestro daemon configure` to bootstrap your config file.");
|
|
||||||
}
|
|
||||||
if systemd_enabled {
|
if systemd_enabled {
|
||||||
eprintln!(" • Check status: `systemctl --user status maestro`");
|
eprintln!(" • Check status: `systemctl --user status maestro`");
|
||||||
eprintln!(" • Logs: `journalctl --user -u maestro -f`");
|
eprintln!(" • Logs: `journalctl --user -u maestro -f`");
|
||||||
} else {
|
} else {
|
||||||
eprintln!(" • Start the daemon: `maestro daemon`");
|
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 web_installed {
|
||||||
if systemd_enabled {
|
if systemd_enabled {
|
||||||
eprintln!(
|
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,13 +5,22 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
description = "Shared wire types for Maestro daemon/registry/client communication"
|
description = "Shared wire types for Maestro daemon/client communication"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# Opt-in `reqwest`-backed client. Off by default so `maestro-web`, which only
|
||||||
|
# needs the wire types and route paths, does not pull an HTTP stack it has its
|
||||||
|
# own copy of.
|
||||||
|
client = ["dep:reqwest"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
reqwest = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! Typed client for the daemon's `/api/*` surface.
|
||||||
|
//!
|
||||||
|
//! Behind the `client` feature so consumers that only need the wire types and
|
||||||
|
//! route paths — `maestro-web`, `maestro-config` — don't pull an HTTP stack.
|
||||||
|
//!
|
||||||
|
//! This is the one place that knows how to call the daemon: the CLI, the TUI and
|
||||||
|
//! the end-to-end harness all go through it.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use reqwest::{Method, Response, StatusCode};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::routes::{self, UrlError};
|
||||||
|
use crate::{
|
||||||
|
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
|
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
/// Total budget for a single request. Bounds the case where the daemon is
|
||||||
|
/// TCP-alive but never makes forward progress.
|
||||||
|
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ClientError {
|
||||||
|
#[error("http: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
/// The daemon answered with a structured [`ApiError`] envelope.
|
||||||
|
#[error("daemon returned {status} ({}): {}", .info.code, .info.message)]
|
||||||
|
Api { status: StatusCode, info: ErrorInfo },
|
||||||
|
/// A non-success response whose body was not an [`ApiError`].
|
||||||
|
#[error("daemon returned {status}: {body}")]
|
||||||
|
Status { status: StatusCode, body: String },
|
||||||
|
#[error("decode: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Url(#[from] UrlError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientError {
|
||||||
|
/// True when the daemon could not be reached at all, rather than reaching it
|
||||||
|
/// and being refused. Drives "is the daemon running?" messaging.
|
||||||
|
pub fn is_unreachable(&self) -> bool {
|
||||||
|
matches!(self, ClientError::Http(e) if e.is_connect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The daemon's own message when it sent one, so callers can render the
|
||||||
|
/// reason rather than a bare status code.
|
||||||
|
pub fn daemon_message(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
ClientError::Api { info, .. } => Some(&info.message),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP client bound to one daemon's base URL.
|
||||||
|
///
|
||||||
|
/// The daemon's surface needs no auth — reaching its bind address is the
|
||||||
|
/// authorization, same trust model as tmux itself.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DaemonClient {
|
||||||
|
base: String,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DaemonClient {
|
||||||
|
/// Client with the standard timeouts.
|
||||||
|
pub fn new(base: impl Into<String>) -> Self {
|
||||||
|
Self::with_timeouts(base, DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client with explicit timeouts, for callers with a tighter budget than the
|
||||||
|
/// default — the TUI's startup health probe wants to fail fast rather than
|
||||||
|
/// hold the terminal for 30 seconds.
|
||||||
|
pub fn with_timeouts(base: impl Into<String>, connect: Duration, request: Duration) -> Self {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.user_agent(concat!("maestro/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.connect_timeout(connect)
|
||||||
|
.timeout(request)
|
||||||
|
.build()
|
||||||
|
.expect("build reqwest client");
|
||||||
|
Self {
|
||||||
|
base: routes::normalize_base(&base.into()).to_string(),
|
||||||
|
http,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base(&self) -> &str {
|
||||||
|
&self.base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `WS /api/events` URL for this daemon.
|
||||||
|
pub fn events_url(&self) -> Result<String, ClientError> {
|
||||||
|
Ok(routes::events_url(&self.base)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `WS /api/attach/:id` URL for this daemon.
|
||||||
|
pub fn attach_url(&self, id: Uuid) -> Result<String, ClientError> {
|
||||||
|
Ok(routes::attach_url(&self.base, id)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- typed calls --------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
||||||
|
self.get(routes::SESSIONS, &[]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_session(&self, req: &CreateSessionRequest) -> Result<Session, ClientError> {
|
||||||
|
self.post(routes::SESSIONS, req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
|
||||||
|
let resp = self
|
||||||
|
.send(Method::DELETE, &routes::session(id), &[], None::<&()>)
|
||||||
|
.await?;
|
||||||
|
check(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||||
|
self.get(routes::INFO, &[]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_dir(&self, path: &str) -> Result<DirListing, ClientError> {
|
||||||
|
self.get(routes::DIRS, &[(routes::PATH_QUERY, path)]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn recent_dirs(&self) -> Result<RecentDirs, ClientError> {
|
||||||
|
self.get(routes::RECENT_DIRS, &[]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn git_root(&self, path: &str) -> Result<GitRoot, ClientError> {
|
||||||
|
self.get(routes::GITROOT, &[(routes::PATH_QUERY, path)])
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- plumbing -----------------------------------------------------------
|
||||||
|
|
||||||
|
async fn get<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, &str)],
|
||||||
|
) -> Result<T, ClientError> {
|
||||||
|
let resp = self.send(Method::GET, path, query, None::<&()>).await?;
|
||||||
|
json(check(resp).await?).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post<B: Serialize, T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<T, ClientError> {
|
||||||
|
let resp = self.send(Method::POST, path, &[], Some(body)).await?;
|
||||||
|
json(check(resp).await?).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send<B: Serialize>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, &str)],
|
||||||
|
body: Option<&B>,
|
||||||
|
) -> Result<Response, ClientError> {
|
||||||
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.request(method, routes::http_url(&self.base, path));
|
||||||
|
if !query.is_empty() {
|
||||||
|
req = req.query(query);
|
||||||
|
}
|
||||||
|
if let Some(b) = body {
|
||||||
|
req = req.json(b);
|
||||||
|
}
|
||||||
|
Ok(req.send().await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a non-success response into the richest error its body supports: the
|
||||||
|
/// daemon's [`ApiError`] envelope when present, the raw body otherwise.
|
||||||
|
async fn check(resp: Response) -> Result<Response, ClientError> {
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
match serde_json::from_str::<ApiError>(&body) {
|
||||||
|
Ok(api) => Err(ClientError::Api {
|
||||||
|
status,
|
||||||
|
info: api.error,
|
||||||
|
}),
|
||||||
|
Err(_) => Err(ClientError::Status { status, body }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn json<T: DeserializeOwned>(resp: Response) -> Result<T, ClientError> {
|
||||||
|
// `204 No Content` is a valid success for the calls that return `()`; the
|
||||||
|
// typed getters never hit it.
|
||||||
|
Ok(resp.json::<T>().await?)
|
||||||
|
}
|
||||||
+144
-1188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
|||||||
|
//! The daemon's `/api/*` surface: paths, and the scheme swap every client
|
||||||
|
//! needs to reach the WebSocket endpoints.
|
||||||
|
//!
|
||||||
|
//! Everything that knows what the daemon's URLs look like lives here.
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Prefix every daemon route shares. `maestro-web` uses it to decide whether a
|
||||||
|
/// request is proxied to the daemon or served from the SPA bundle.
|
||||||
|
pub const API_PREFIX: &str = "/api";
|
||||||
|
|
||||||
|
/// `GET` list / `POST` spawn.
|
||||||
|
pub const SESSIONS: &str = "/api/sessions";
|
||||||
|
/// `GET` daemon identity, version, and spawn presets.
|
||||||
|
pub const INFO: &str = "/api/info";
|
||||||
|
/// `GET` one directory's immediate subdirectories. Takes a `path` query param.
|
||||||
|
pub const DIRS: &str = "/api/dirs";
|
||||||
|
/// `GET` spawn history.
|
||||||
|
pub const RECENT_DIRS: &str = "/api/recent-dirs";
|
||||||
|
/// `GET` the enclosing repository of a path. Takes a `path` query param.
|
||||||
|
pub const GITROOT: &str = "/api/gitroot";
|
||||||
|
/// `WS` catalog snapshot + session deltas.
|
||||||
|
pub const EVENTS: &str = "/api/events";
|
||||||
|
|
||||||
|
/// Name of the query parameter [`DIRS`] and [`GITROOT`] both take.
|
||||||
|
pub const PATH_QUERY: &str = "path";
|
||||||
|
|
||||||
|
/// `DELETE /api/sessions/:id`.
|
||||||
|
pub fn session(id: Uuid) -> String {
|
||||||
|
format!("{SESSIONS}/{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `WS /api/attach/:id` — raw PTY bytes.
|
||||||
|
pub fn attach(id: Uuid) -> String {
|
||||||
|
format!("{API_PREFIX}/attach/{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axum-style patterns for the two parameterised routes, so the server
|
||||||
|
/// registers the same paths [`session`] and [`attach`] build.
|
||||||
|
pub mod patterns {
|
||||||
|
pub const SESSION: &str = "/api/sessions/:id";
|
||||||
|
pub const ATTACH: &str = "/api/attach/:id";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum UrlError {
|
||||||
|
#[error("daemon url has no supported scheme (want http/https/ws/wss): {0}")]
|
||||||
|
UnsupportedScheme(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip a trailing slash so `{base}{path}` never doubles up.
|
||||||
|
pub fn normalize_base(base: &str) -> &str {
|
||||||
|
base.trim_end_matches('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join a daemon base URL and one of this module's paths.
|
||||||
|
pub fn http_url(base: &str, path: &str) -> String {
|
||||||
|
format!("{}{}", normalize_base(base), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swap an HTTP base URL for its WebSocket equivalent.
|
||||||
|
///
|
||||||
|
/// `http://` becomes `ws://`, `https://` becomes `wss://`, and a base that is
|
||||||
|
/// already `ws://`/`wss://` passes through. Anything else is an error — a
|
||||||
|
/// scheme-less base would otherwise produce a URL that fails much later, at
|
||||||
|
/// connect time, with nothing pointing back at the configuration that caused
|
||||||
|
/// it.
|
||||||
|
pub fn ws_base(http_base: &str) -> Result<String, UrlError> {
|
||||||
|
let base = normalize_base(http_base);
|
||||||
|
if let Some(rest) = base.strip_prefix("https://") {
|
||||||
|
Ok(format!("wss://{rest}"))
|
||||||
|
} else if let Some(rest) = base.strip_prefix("http://") {
|
||||||
|
Ok(format!("ws://{rest}"))
|
||||||
|
} else if base.starts_with("wss://") || base.starts_with("ws://") {
|
||||||
|
Ok(base.to_string())
|
||||||
|
} else {
|
||||||
|
Err(UrlError::UnsupportedScheme(http_base.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full `WS /api/events` URL for a daemon reachable at `http_base`.
|
||||||
|
pub fn events_url(http_base: &str) -> Result<String, UrlError> {
|
||||||
|
Ok(format!("{}{}", ws_base(http_base)?, EVENTS))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full `WS /api/attach/:id` URL for a daemon reachable at `http_base`.
|
||||||
|
pub fn attach_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
|
||||||
|
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn id() -> Uuid {
|
||||||
|
Uuid::parse_str("6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ws_base_swaps_http_and_https() {
|
||||||
|
assert_eq!(
|
||||||
|
ws_base("http://127.0.0.1:7070").unwrap(),
|
||||||
|
"ws://127.0.0.1:7070"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ws_base("https://laptop.lan:7070").unwrap(),
|
||||||
|
"wss://laptop.lan:7070"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ws_base_passes_through_websocket_schemes() {
|
||||||
|
assert_eq!(ws_base("ws://10.0.0.1:7070").unwrap(), "ws://10.0.0.1:7070");
|
||||||
|
assert_eq!(
|
||||||
|
ws_base("wss://10.0.0.1:7070").unwrap(),
|
||||||
|
"wss://10.0.0.1:7070"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ws_base_rejects_an_unknown_scheme() {
|
||||||
|
assert_eq!(
|
||||||
|
ws_base("127.0.0.1:7070"),
|
||||||
|
Err(UrlError::UnsupportedScheme("127.0.0.1:7070".into()))
|
||||||
|
);
|
||||||
|
assert!(ws_base("ftp://host:7070").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_slashes_never_double_up() {
|
||||||
|
assert_eq!(ws_base("http://host:7070/").unwrap(), "ws://host:7070");
|
||||||
|
assert_eq!(
|
||||||
|
http_url("http://host:7070/", SESSIONS),
|
||||||
|
"http://host:7070/api/sessions"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events_url("http://host:7070/").unwrap(),
|
||||||
|
"ws://host:7070/api/events"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_per_session_paths() {
|
||||||
|
assert_eq!(
|
||||||
|
session(id()),
|
||||||
|
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
attach(id()),
|
||||||
|
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
||||||
|
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server registers `patterns::*` while clients call `session`/`attach`;
|
||||||
|
/// nothing but this test keeps the two spellings in step.
|
||||||
|
#[test]
|
||||||
|
fn patterns_and_builders_agree() {
|
||||||
|
assert_eq!(
|
||||||
|
patterns::SESSION.trim_end_matches(":id"),
|
||||||
|
session(id()).trim_end_matches(&id().to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
patterns::ATTACH.trim_end_matches(":id"),
|
||||||
|
attach(id()).trim_end_matches(&id().to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_route_sits_under_the_api_prefix() {
|
||||||
|
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, GITROOT, EVENTS] {
|
||||||
|
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||||
|
}
|
||||||
|
assert!(attach(id()).starts_with(API_PREFIX));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "maestro-testing"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "In-memory Maestro daemon for tests: the real /api/* surface without tmux"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
futures-util.workspace = true
|
||||||
|
maestro-protocol = { workspace = true, features = ["client"] }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
//! An in-memory Maestro daemon for tests.
|
||||||
|
//!
|
||||||
|
//! Serves the real `/api/*` surface from `maestro-protocol` types, backed by a
|
||||||
|
//! `Vec<Session>` instead of tmux. Use it whenever the subject under test is a
|
||||||
|
//! client — request building, error decoding, the catalog reducer, the proxy —
|
||||||
|
//! rather than session management itself. Tests that genuinely need tmux belong
|
||||||
|
//! in `maestro-e2e` against a real daemon.
|
||||||
|
|
||||||
|
#![deny(missing_debug_implementations)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::{any, delete, get};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use chrono::Utc;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use maestro_protocol::{
|
||||||
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
|
||||||
|
GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::{broadcast, oneshot};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
|
||||||
|
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||||
|
Session {
|
||||||
|
id,
|
||||||
|
cwd: cwd.into(),
|
||||||
|
command: command.into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
status: SessionStatus::Running,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
foreground: None,
|
||||||
|
activity: Default::default(),
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
current_cwd: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Inner {
|
||||||
|
sessions: Vec<Session>,
|
||||||
|
info: Option<DaemonInfo>,
|
||||||
|
/// When set, every route answers with this instead of its normal response.
|
||||||
|
forced_error: Option<(u16, ApiError)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
inner: Arc<Mutex<Inner>>,
|
||||||
|
events: broadcast::Sender<CatalogEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for AppState {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("AppState").finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running mock daemon. Shuts down when dropped.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MockDaemon {
|
||||||
|
addr: SocketAddr,
|
||||||
|
state: AppState,
|
||||||
|
_shutdown: oneshot::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockDaemon {
|
||||||
|
pub async fn start() -> Self {
|
||||||
|
let state = AppState {
|
||||||
|
inner: Arc::new(Mutex::new(Inner::default())),
|
||||||
|
events: broadcast::channel(64).0,
|
||||||
|
};
|
||||||
|
let app = Router::new()
|
||||||
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
|
.route(routes::patterns::SESSION, delete(kill_session))
|
||||||
|
.route(routes::INFO, get(daemon_info))
|
||||||
|
.route(routes::DIRS, get(list_dir))
|
||||||
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
|
.route(routes::GITROOT, get(git_root))
|
||||||
|
.route(routes::EVENTS, any(events_ws))
|
||||||
|
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||||
|
.with_state(state.clone());
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("local_addr");
|
||||||
|
let (tx, rx) = oneshot::channel::<()>();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
let _ = rx.await;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
addr,
|
||||||
|
state,
|
||||||
|
_shutdown: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn addr(&self) -> SocketAddr {
|
||||||
|
self.addr
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn http_base(&self) -> String {
|
||||||
|
format!("http://{}", self.addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client bound to this daemon.
|
||||||
|
pub fn client(&self) -> DaemonClient {
|
||||||
|
DaemonClient::new(self.http_base())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a session and announce it, as a real spawn would.
|
||||||
|
pub fn add_session(&self, session: Session) {
|
||||||
|
self.state
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("poisoned")
|
||||||
|
.sessions
|
||||||
|
.push(session.clone());
|
||||||
|
let _ = self
|
||||||
|
.state
|
||||||
|
.events
|
||||||
|
.send(CatalogEvent::SessionAdded { session });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a session and announce it.
|
||||||
|
pub fn remove_session(&self, id: Uuid) {
|
||||||
|
self.state
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("poisoned")
|
||||||
|
.sessions
|
||||||
|
.retain(|s| s.id != id);
|
||||||
|
let _ = self.state.events.send(CatalogEvent::SessionRemoved { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_info(&self, info: DaemonInfo) {
|
||||||
|
self.state.inner.lock().expect("poisoned").info = Some(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make every route answer with this error, so clients' failure paths — and
|
||||||
|
/// their [`ApiError`] decoding — can be exercised.
|
||||||
|
pub fn force_error(&self, status: u16, code: &str, message: &str) {
|
||||||
|
self.state.inner.lock().expect("poisoned").forced_error =
|
||||||
|
Some((status, ApiError::new(code, message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_error(&self) {
|
||||||
|
self.state.inner.lock().expect("poisoned").forced_error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_count(&self) -> usize {
|
||||||
|
self.state.inner.lock().expect("poisoned").sessions.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forced(state: &AppState) -> Option<Response> {
|
||||||
|
let guard = state.inner.lock().expect("poisoned");
|
||||||
|
guard.forced_error.as_ref().map(|(status, body)| {
|
||||||
|
(
|
||||||
|
StatusCode::from_u16(*status).expect("valid status"),
|
||||||
|
Json(body.clone()),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_sessions(State(s): State<AppState>) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let sessions = s.inner.lock().expect("poisoned").sessions.clone();
|
||||||
|
Json(sessions).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_session(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Json(req): Json<CreateSessionRequest>,
|
||||||
|
) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let mut created = session(Uuid::new_v4(), &req.cwd.to_string_lossy(), &req.command);
|
||||||
|
created.env = req.env;
|
||||||
|
created.metadata = req.metadata;
|
||||||
|
s.inner
|
||||||
|
.lock()
|
||||||
|
.expect("poisoned")
|
||||||
|
.sessions
|
||||||
|
.push(created.clone());
|
||||||
|
let _ = s.events.send(CatalogEvent::SessionAdded {
|
||||||
|
session: created.clone(),
|
||||||
|
});
|
||||||
|
Json(created).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn kill_session(State(s): State<AppState>, Path(id): Path<Uuid>) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let existed = {
|
||||||
|
let mut guard = s.inner.lock().expect("poisoned");
|
||||||
|
let before = guard.sessions.len();
|
||||||
|
guard.sessions.retain(|x| x.id != id);
|
||||||
|
guard.sessions.len() != before
|
||||||
|
};
|
||||||
|
if !existed {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
let _ = s.events.send(CatalogEvent::SessionRemoved { id });
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn daemon_info(State(s): State<AppState>) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
let info = s
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("poisoned")
|
||||||
|
.info
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| DaemonInfo {
|
||||||
|
hostname: "mock-host".into(),
|
||||||
|
version: PROTOCOL_VERSION.into(),
|
||||||
|
home_dir: "/home/mock".into(),
|
||||||
|
presets: Vec::new(),
|
||||||
|
});
|
||||||
|
Json(info).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_dir(State(s): State<AppState>, Query(q): Query<PathQuery>) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
Json(DirListing {
|
||||||
|
path: q.path.into(),
|
||||||
|
entries: Vec::new(),
|
||||||
|
truncated: false,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recent_dirs(State(s): State<AppState>) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
Json(RecentDirs {
|
||||||
|
dirs: Vec::new(),
|
||||||
|
truncated: false,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn git_root(State(s): State<AppState>, Query(_q): Query<PathQuery>) -> Response {
|
||||||
|
if let Some(r) = forced(&s) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
Json(GitRoot { root: None }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot on connect, then live deltas — the same contract the daemon offers.
|
||||||
|
async fn events_ws(State(s): State<AppState>, ws: WebSocketUpgrade) -> Response {
|
||||||
|
ws.on_upgrade(move |socket| events_loop(s, socket))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn events_loop(s: AppState, socket: WebSocket) {
|
||||||
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
let mut rx = s.events.subscribe();
|
||||||
|
let snapshot = CatalogEvent::Snapshot {
|
||||||
|
sessions: s.inner.lock().expect("poisoned").sessions.clone(),
|
||||||
|
};
|
||||||
|
if sink
|
||||||
|
.send(Message::Text(
|
||||||
|
serde_json::to_string(&snapshot).expect("encode"),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
ev = rx.recv() => {
|
||||||
|
let Ok(ev) = ev else { break };
|
||||||
|
let text = serde_json::to_string(&ev).expect("encode");
|
||||||
|
if sink.send(Message::Text(text)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame = stream.next() => {
|
||||||
|
match frame {
|
||||||
|
None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break,
|
||||||
|
Some(Ok(_)) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Echoes whatever it is sent, so a client's PTY plumbing can be exercised.
|
||||||
|
async fn attach_ws(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
) -> Response {
|
||||||
|
let known = s
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("poisoned")
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.any(|x| x.id == id);
|
||||||
|
if !known {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ApiError::new("not_found", "no such session on this daemon")),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
ws.on_upgrade(|mut socket| async move {
|
||||||
|
while let Some(Ok(msg)) = socket.next().await {
|
||||||
|
match msg {
|
||||||
|
Message::Binary(b) => {
|
||||||
|
if socket.send(Message::Binary(b)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Text(t) => {
|
||||||
|
if socket.send(Message::Text(t)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Close(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct PathQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
@@ -12,9 +12,8 @@ clap.workspace = true
|
|||||||
crossterm.workspace = true
|
crossterm.workspace = true
|
||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
maestro-protocol.workspace = true
|
maestro-protocol = { workspace = true, features = ["client"] }
|
||||||
ratatui.workspace = true
|
ratatui.workspace = true
|
||||||
reqwest.workspace = true
|
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||||
@@ -24,6 +23,7 @@ uuid.workspace = true
|
|||||||
vt100.workspace = true
|
vt100.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
maestro-testing.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt-multi-thread", "net"] }
|
tokio = { workspace = true, features = ["sync", "time", "macros", "rt-multi-thread", "net"] }
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub enum Action {
|
|||||||
// Catalog actions on the highlighted sidebar row
|
// Catalog actions on the highlighted sidebar row
|
||||||
AttachHighlighted,
|
AttachHighlighted,
|
||||||
ToggleAttachHighlighted,
|
ToggleAttachHighlighted,
|
||||||
OpenSpawnModal,
|
NewDraftPane,
|
||||||
KillHighlighted,
|
KillHighlighted,
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
@@ -78,7 +78,7 @@ impl Action {
|
|||||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||||
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
|
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
"NewTab" => return Ok(Action::NewTab),
|
"NewTab" => return Ok(Action::NewTab),
|
||||||
"CloseTab" => return Ok(Action::CloseTab),
|
"CloseTab" => return Ok(Action::CloseTab),
|
||||||
@@ -202,6 +202,7 @@ mod tests {
|
|||||||
for a in [
|
for a in [
|
||||||
Action::EnterInsert,
|
Action::EnterInsert,
|
||||||
Action::Quit,
|
Action::Quit,
|
||||||
|
Action::NewDraftPane,
|
||||||
Action::FocusPane(Direction::Right),
|
Action::FocusPane(Direction::Right),
|
||||||
Action::GotoTab(7),
|
Action::GotoTab(7),
|
||||||
] {
|
] {
|
||||||
@@ -209,4 +210,14 @@ mod tests {
|
|||||||
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The spawn modal is gone (issue #63); its old action string must not
|
||||||
|
/// resolve to anything.
|
||||||
|
#[test]
|
||||||
|
fn the_removed_spawn_modal_action_no_longer_parses() {
|
||||||
|
assert_eq!(
|
||||||
|
Action::parse("OpenSpawnModal").unwrap_err(),
|
||||||
|
ActionParseError::Unknown("OpenSpawnModal".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+404
-212
@@ -6,9 +6,9 @@
|
|||||||
//! changes, and a 30Hz heartbeat.
|
//! changes, and a 30Hz heartbeat.
|
||||||
//!
|
//!
|
||||||
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
|
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
|
||||||
//! (`AttachHighlighted` spawning a `SessionTask`, `OpenSpawnModal` submit
|
//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing
|
||||||
//! firing a POST, `KillHighlighted` confirm firing a DELETE) live inline in
|
//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in
|
||||||
//! `dispatch` / `handle_overlay_input`.
|
//! `dispatch` / the Insert-mode pane routing.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Stdout;
|
use std::io::Stdout;
|
||||||
@@ -16,7 +16,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crossterm::event::{Event as CtEvent, EventStream};
|
use crossterm::event::{Event as CtEvent, EventStream};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use maestro_protocol::CreateSessionRequest;
|
use maestro_protocol::{CreateSessionRequest, DaemonClient};
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -24,15 +24,16 @@ use uuid::Uuid;
|
|||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
use crate::catalog::CatalogStore;
|
use crate::catalog::CatalogStore;
|
||||||
use crate::config::TuiConfig;
|
use crate::config::TuiConfig;
|
||||||
|
use crate::draft::DraftInputResult;
|
||||||
use crate::emulators::SessionEmulators;
|
use crate::emulators::SessionEmulators;
|
||||||
use crate::input::{canonicalise, ChordResult, ChordState, InputKey};
|
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::{HelpOverlay, SpawnModal};
|
use crate::overlays::HelpOverlay;
|
||||||
use crate::pane::{Pane, PaneStatus};
|
use crate::pane::{Pane, PaneStatus};
|
||||||
use crate::render::{draw, RegionMap, RenderInputs};
|
use crate::render::{draw, RegionMap, RenderInputs};
|
||||||
use crate::session_task::{SessionInput, SessionTask};
|
use crate::session_task::{SessionInput, SessionTask};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind};
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
||||||
use crate::tabs::TabBar;
|
use crate::tabs::TabBar;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
@@ -49,26 +50,37 @@ pub struct App {
|
|||||||
catalog: CatalogStore,
|
catalog: CatalogStore,
|
||||||
emulators: SessionEmulators,
|
emulators: SessionEmulators,
|
||||||
state: AppState,
|
state: AppState,
|
||||||
spawn: SpawnModal,
|
|
||||||
help: HelpOverlay,
|
help: HelpOverlay,
|
||||||
chord: ChordState,
|
chord: ChordState,
|
||||||
daemon_base_url: String,
|
daemon: DaemonClient,
|
||||||
region: RegionMap,
|
region: RegionMap,
|
||||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||||
http: reqwest::Client,
|
clicks: ClickTracker,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for App {
|
impl std::fmt::Debug for App {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("App")
|
f.debug_struct("App")
|
||||||
.field("daemon_base_url", &self.daemon_base_url)
|
.field("daemon", &self.daemon.base())
|
||||||
.field("state", &self.state)
|
.field("state", &self.state)
|
||||||
.finish_non_exhaustive()
|
.finish_non_exhaustive()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Something a keystroke decided to do that reaches outside `AppState`.
|
||||||
|
///
|
||||||
|
/// `dispatch` returns these instead of performing them, so every arm is
|
||||||
|
/// assertable without a daemon on the other end.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Effect {
|
||||||
|
Attach(Uuid),
|
||||||
|
Spawn(CreateSessionRequest),
|
||||||
|
Kill(Uuid),
|
||||||
|
Forward { id: Uuid, bytes: Vec<u8> },
|
||||||
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon_base_url: String) -> Self {
|
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self {
|
||||||
let chord_timeout = cfg.chord_timeout;
|
let chord_timeout = cfg.chord_timeout;
|
||||||
let help = HelpOverlay {
|
let help = HelpOverlay {
|
||||||
lines: render_help_lines(&cfg),
|
lines: render_help_lines(&cfg),
|
||||||
@@ -78,16 +90,12 @@ impl App {
|
|||||||
catalog,
|
catalog,
|
||||||
emulators: SessionEmulators::default(),
|
emulators: SessionEmulators::default(),
|
||||||
state: AppState::new(),
|
state: AppState::new(),
|
||||||
spawn: SpawnModal::default(),
|
|
||||||
help,
|
help,
|
||||||
chord: ChordState::new(chord_timeout),
|
chord: ChordState::new(chord_timeout),
|
||||||
daemon_base_url,
|
daemon,
|
||||||
region: RegionMap::default(),
|
region: RegionMap::default(),
|
||||||
last_sent_size: HashMap::new(),
|
last_sent_size: HashMap::new(),
|
||||||
http: reqwest::Client::builder()
|
clicks: ClickTracker::default(),
|
||||||
.timeout(Duration::from_secs(5))
|
|
||||||
.build()
|
|
||||||
.expect("reqwest client"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +130,12 @@ impl App {
|
|||||||
let Some(key) = canonicalise(&ev, |c, r| map.region_at(c, r)) else {
|
let Some(key) = canonicalise(&ev, |c, r| map.region_at(c, r)) else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
let key = self.clicks.promote(key, std::time::Instant::now());
|
||||||
|
|
||||||
// Overlay intercept first.
|
// Overlay intercept first.
|
||||||
if self.handle_overlay_input(&key).await == OverlayDecision::Consumed {
|
let (decision, effects) = self.handle_overlay_input(&key);
|
||||||
|
self.perform(effects).await;
|
||||||
|
if decision == OverlayDecision::Consumed {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,52 +147,75 @@ impl App {
|
|||||||
if matches!(act, Action::Quit) {
|
if matches!(act, Action::Quit) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
self.dispatch(act).await;
|
let effects = self.dispatch(act);
|
||||||
|
self.perform(effects).await;
|
||||||
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
|
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
|
||||||
{
|
{
|
||||||
self.forward_to_session(&key).await;
|
// Insert mode over a focused pane: a draft eats the key
|
||||||
|
// into its form, a session gets the raw bytes.
|
||||||
|
let effect = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
|
||||||
|
{
|
||||||
|
self.handle_draft_input(&key)
|
||||||
|
} else {
|
||||||
|
self.forward_to_session(&key)
|
||||||
|
};
|
||||||
|
if let Some(effect) = effect {
|
||||||
|
self.perform(vec![effect]).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_overlay_input(&mut self, key: &InputKey) -> OverlayDecision {
|
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||||
use crate::overlays::SpawnInputResult;
|
/// the daemon.
|
||||||
match self.state.overlay.clone() {
|
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||||
Some(OverlayKind::Spawn) => {
|
for effect in effects {
|
||||||
match self.spawn.on_input(key) {
|
match effect {
|
||||||
SpawnInputResult::Consumed => {}
|
Effect::Attach(id) => self.attach(id),
|
||||||
SpawnInputResult::Cancel => {
|
Effect::Spawn(req) => self.submit_spawn(req).await,
|
||||||
self.state.overlay = None;
|
Effect::Kill(id) => self.submit_kill(id).await,
|
||||||
}
|
Effect::Forward { id, bytes } => {
|
||||||
SpawnInputResult::Submit => {
|
if let Some(handle) = self.emulators.get(id) {
|
||||||
self.submit_spawn().await;
|
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OverlayDecision::Consumed
|
|
||||||
}
|
}
|
||||||
Some(OverlayKind::KillConfirm(id)) => match key {
|
|
||||||
InputKey::Char { c: 'y', .. } => {
|
|
||||||
self.state.overlay = None;
|
|
||||||
self.submit_kill(id).await;
|
|
||||||
OverlayDecision::Consumed
|
|
||||||
}
|
|
||||||
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
|
|
||||||
self.state.overlay = None;
|
|
||||||
OverlayDecision::Consumed
|
|
||||||
}
|
|
||||||
_ => OverlayDecision::Consumed,
|
|
||||||
},
|
|
||||||
Some(OverlayKind::Help) => {
|
|
||||||
self.state.overlay = None;
|
|
||||||
OverlayDecision::Consumed
|
|
||||||
}
|
|
||||||
Some(OverlayKind::Toast(_)) | None => OverlayDecision::Pass,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch(&mut self, act: Action) {
|
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
|
||||||
|
match self.state.overlay().cloned() {
|
||||||
|
Some(OverlayKind::KillConfirm(id)) => match key {
|
||||||
|
InputKey::Char { c: 'y', .. } => {
|
||||||
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Consumed, vec![Effect::Kill(id)])
|
||||||
|
}
|
||||||
|
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
|
||||||
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
_ => (OverlayDecision::Consumed, Vec::new()),
|
||||||
|
},
|
||||||
|
Some(OverlayKind::Help) => {
|
||||||
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
// A toast is informational: clear it, but let the keystroke go on
|
||||||
|
// and do its normal job rather than being swallowed.
|
||||||
|
Some(OverlayKind::Toast(_)) => {
|
||||||
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Pass, Vec::new())
|
||||||
|
}
|
||||||
|
None => (OverlayDecision::Pass, Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold a resolved action into `AppState` and report anything it wants done
|
||||||
|
/// outside it. Deliberately sync and side-effect-free.
|
||||||
|
fn dispatch(&mut self, act: Action) -> Vec<Effect> {
|
||||||
|
let mut effects = Vec::new();
|
||||||
match act {
|
match act {
|
||||||
Action::Noop | Action::Quit => {}
|
Action::Noop | Action::Quit => {}
|
||||||
Action::EnterInsert => self.state.mode = Mode::Insert,
|
Action::EnterInsert => self.state.mode = Mode::Insert,
|
||||||
@@ -203,7 +237,11 @@ impl App {
|
|||||||
self.state.focus = Focus::Sidebar;
|
self.state.focus = Focus::Sidebar;
|
||||||
}
|
}
|
||||||
Action::NewTab => self.state.new_tab(),
|
Action::NewTab => self.state.new_tab(),
|
||||||
Action::CloseTab => self.state.close_tab(),
|
Action::CloseTab => {
|
||||||
|
if let Some(sid) = self.state.close_tab() {
|
||||||
|
self.release_session(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::NextTab => self.state.next_tab(),
|
Action::NextTab => self.state.next_tab(),
|
||||||
Action::PrevTab => self.state.prev_tab(),
|
Action::PrevTab => self.state.prev_tab(),
|
||||||
Action::GotoTab(n) => self.state.goto_tab(n),
|
Action::GotoTab(n) => self.state.goto_tab(n),
|
||||||
@@ -212,37 +250,43 @@ impl App {
|
|||||||
self.help = HelpOverlay {
|
self.help = HelpOverlay {
|
||||||
lines: render_help_lines(&self.cfg),
|
lines: render_help_lines(&self.cfg),
|
||||||
};
|
};
|
||||||
self.state.overlay = Some(OverlayKind::Help);
|
self.state.show_overlay(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(),
|
|
||||||
};
|
|
||||||
self.state.overlay = Some(OverlayKind::Spawn);
|
|
||||||
}
|
}
|
||||||
|
Action::NewDraftPane => self.state.open_draft_pane(),
|
||||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
if self.emulators.get(sid).is_none() {
|
effects.push(Effect::Attach(sid));
|
||||||
let ws_url = format!("{}/api/attach/{sid}", ws_base(&self.daemon_base_url));
|
|
||||||
let handle = SessionTask::spawn(ws_url, 24, 80);
|
|
||||||
self.emulators.insert(sid, handle);
|
|
||||||
}
|
|
||||||
self.state.attach_to_focused(sid);
|
self.state.attach_to_focused(sid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::KillHighlighted => {
|
Action::KillHighlighted => {
|
||||||
if let Some(sid) = self.pick_sidebar_target() {
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
self.state.overlay = Some(OverlayKind::KillConfirm(sid));
|
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a stream for `sid` if one isn't already running.
|
||||||
|
fn attach(&mut self, sid: Uuid) {
|
||||||
|
if self.emulators.get(sid).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match self.daemon.attach_url(sid) {
|
||||||
|
Ok(ws_url) => {
|
||||||
|
let handle = SessionTask::spawn(ws_url, 24, 80);
|
||||||
|
self.emulators.insert(sid, handle);
|
||||||
|
}
|
||||||
|
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down the emulator for `sid`, aborting its WebSocket task and
|
||||||
|
/// dropping its vt100 parser.
|
||||||
|
fn release_session(&mut self, sid: Uuid) {
|
||||||
|
self.emulators.remove(sid);
|
||||||
|
self.last_sent_size.remove(&sid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the session under the sidebar highlight, falling back to the
|
/// Return the session under the sidebar highlight, falling back to the
|
||||||
@@ -255,89 +299,50 @@ impl App {
|
|||||||
snap.sessions.first().map(|s| s.id)
|
snap.sessions.first().map(|s| s.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn forward_to_session(&mut self, key: &InputKey) {
|
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
||||||
let Some(sid) = self.state.active_tab().pane else {
|
/// attached to send it to.
|
||||||
return;
|
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
};
|
let sid = self.state.active_session()?;
|
||||||
let Some(handle) = self.emulators.get(sid) else {
|
self.emulators.get(sid)?;
|
||||||
return;
|
let bytes = encode_key(key)?;
|
||||||
};
|
Some(Effect::Forward { id: sid, bytes })
|
||||||
let bytes = match key {
|
|
||||||
InputKey::Char { c, ctrl, alt, .. } => {
|
|
||||||
let mut out = Vec::with_capacity(8);
|
|
||||||
if *alt {
|
|
||||||
out.push(0x1b);
|
|
||||||
}
|
|
||||||
if *ctrl && c.is_ascii_alphabetic() {
|
|
||||||
out.push((c.to_ascii_uppercase() as u8) - b'@');
|
|
||||||
} else {
|
|
||||||
let mut b = [0u8; 4];
|
|
||||||
let s = c.encode_utf8(&mut b);
|
|
||||||
out.extend_from_slice(s.as_bytes());
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
InputKey::Enter => b"\r".to_vec(),
|
|
||||||
InputKey::Tab => b"\t".to_vec(),
|
|
||||||
InputKey::Backspace => vec![0x7f],
|
|
||||||
InputKey::Esc => vec![0x1b],
|
|
||||||
InputKey::Up => b"\x1b[A".to_vec(),
|
|
||||||
InputKey::Down => b"\x1b[B".to_vec(),
|
|
||||||
InputKey::Right => b"\x1b[C".to_vec(),
|
|
||||||
InputKey::Left => b"\x1b[D".to_vec(),
|
|
||||||
InputKey::Mouse { .. } => return,
|
|
||||||
};
|
|
||||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn submit_spawn(&mut self) {
|
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
||||||
let daemon_id = match self.spawn.daemons.get(self.spawn.daemon_idx) {
|
/// effect when the user accepted the form.
|
||||||
Some(d) => d.daemon_id.clone(),
|
fn handle_draft_input(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
None => {
|
let PaneRef::Draft(draft) = self.state.active_tab_mut().pane.as_mut()? else {
|
||||||
self.state.overlay = Some(OverlayKind::Toast("spawn: pick a daemon first".into()));
|
return None;
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let cwd = std::path::PathBuf::from(self.spawn.cwd.clone());
|
if draft.on_input(key) != DraftInputResult::Submit {
|
||||||
let req = CreateSessionRequest {
|
return None;
|
||||||
daemon_id,
|
}
|
||||||
cwd,
|
Some(Effect::Spawn(CreateSessionRequest {
|
||||||
command: self.spawn.command.clone(),
|
cwd: std::path::PathBuf::from(draft.cwd.value().to_string()),
|
||||||
|
command: draft.command.value().to_string(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
};
|
}))
|
||||||
let url = format!("{}/api/sessions", self.daemon_base_url);
|
}
|
||||||
match self.http.post(&url).json(&req).send().await {
|
|
||||||
Ok(res) if res.status().is_success() => {
|
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
||||||
self.state.overlay = None;
|
match self.daemon.create_session(&req).await {
|
||||||
}
|
Ok(session) => {
|
||||||
Ok(res) => {
|
// Fill the pane in place with the new session (web parity,
|
||||||
let status = res.status();
|
// #57) and attach to it right away.
|
||||||
self.state.overlay =
|
let sid = session.id;
|
||||||
Some(OverlayKind::Toast(format!("spawn failed: HTTP {status}")));
|
self.state.complete_spawn(sid);
|
||||||
}
|
self.attach(sid);
|
||||||
Err(e) => {
|
|
||||||
self.state.overlay = Some(OverlayKind::Toast(format!("spawn failed: {e}")));
|
|
||||||
}
|
}
|
||||||
|
Err(e) => self.state.toast(format!("spawn failed: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn submit_kill(&mut self, sid: Uuid) {
|
async fn submit_kill(&mut self, sid: Uuid) {
|
||||||
let url = format!("{}/api/sessions/{sid}", self.daemon_base_url);
|
match self.daemon.kill_session(sid).await {
|
||||||
match self.http.delete(&url).send().await {
|
Ok(()) => self.release_session(sid),
|
||||||
Ok(res) if res.status().is_success() => {
|
Err(e) => self.state.toast(format!("kill failed: {e}")),
|
||||||
self.emulators.remove(sid);
|
|
||||||
self.last_sent_size.remove(&sid);
|
|
||||||
}
|
|
||||||
Ok(res) => {
|
|
||||||
let status = res.status();
|
|
||||||
self.state.overlay =
|
|
||||||
Some(OverlayKind::Toast(format!("kill failed: HTTP {status}")));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.state.overlay = Some(OverlayKind::Toast(format!("kill failed: {e}")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,9 +351,13 @@ impl App {
|
|||||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
) -> Result<(), TuiError> {
|
) -> Result<(), TuiError> {
|
||||||
let snap = self.catalog.snapshot();
|
let snap = self.catalog.snapshot();
|
||||||
let attached: HashSet<Uuid> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
|
let attached: HashSet<Uuid> = self
|
||||||
|
.state
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.filter_map(crate::state::Tab::session)
|
||||||
|
.collect();
|
||||||
let sidebar = Sidebar {
|
let sidebar = Sidebar {
|
||||||
daemons: snap.daemons.clone(),
|
|
||||||
sessions: snap.sessions.clone(),
|
sessions: snap.sessions.clone(),
|
||||||
highlight: self.state.sidebar_highlight,
|
highlight: self.state.sidebar_highlight,
|
||||||
attached,
|
attached,
|
||||||
@@ -357,7 +366,7 @@ impl App {
|
|||||||
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
|
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
|
||||||
active_idx: self.state.active_tab_idx,
|
active_idx: self.state.active_tab_idx,
|
||||||
};
|
};
|
||||||
let active_sid = self.state.active_tab().pane;
|
let active_sid = self.state.active_session();
|
||||||
let pane = active_sid.and_then(|sid| {
|
let pane = active_sid.and_then(|sid| {
|
||||||
self.emulators.get(sid).map(|h| Pane {
|
self.emulators.get(sid).map(|h| Pane {
|
||||||
screen: h.screen.clone(),
|
screen: h.screen.clone(),
|
||||||
@@ -365,13 +374,17 @@ impl App {
|
|||||||
focused: matches!(self.state.focus, Focus::Pane),
|
focused: matches!(self.state.focus, Focus::Pane),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let draft = match self.state.active_tab().pane.as_ref() {
|
||||||
|
Some(PaneRef::Draft(d)) => Some(d),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
tab_bar: &tab_bar,
|
tab_bar: &tab_bar,
|
||||||
pane: pane.as_ref(),
|
pane: pane.as_ref(),
|
||||||
state: &self.state,
|
state: &self.state,
|
||||||
spawn: &self.spawn,
|
draft,
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -412,26 +425,47 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Order the sidebar walks for `j`/`k`: each daemon's sessions, in the same
|
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
|
||||||
/// order [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight
|
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
|
||||||
/// movement in visual sync with what the user sees.
|
/// visual sync with what the user sees.
|
||||||
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
|
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
|
||||||
let mut out = Vec::with_capacity(snap.sessions.len());
|
snap.sessions.iter().map(|s| s.id).collect()
|
||||||
for d in &snap.daemons {
|
|
||||||
for s in snap.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
|
|
||||||
out.push(s.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ws_base(http: &str) -> String {
|
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
||||||
if let Some(rest) = http.strip_prefix("http://") {
|
/// with no byte representation (mouse events). A bracketed paste is forwarded
|
||||||
format!("ws://{rest}")
|
/// verbatim — the remote readline reassembles it, since the pane's PTY also
|
||||||
} else if let Some(rest) = http.strip_prefix("https://") {
|
/// has bracketed paste enabled.
|
||||||
format!("wss://{rest}")
|
fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
||||||
} else {
|
match key {
|
||||||
http.to_string()
|
InputKey::Char { c, ctrl, alt, .. } => {
|
||||||
|
let mut out = Vec::with_capacity(8);
|
||||||
|
if *alt {
|
||||||
|
out.push(0x1b);
|
||||||
|
}
|
||||||
|
if *ctrl && c.is_ascii_alphabetic() {
|
||||||
|
out.push((c.to_ascii_uppercase() as u8) - b'@');
|
||||||
|
} else {
|
||||||
|
let mut b = [0u8; 4];
|
||||||
|
let s = c.encode_utf8(&mut b);
|
||||||
|
out.extend_from_slice(s.as_bytes());
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
InputKey::Enter => Some(b"\r".to_vec()),
|
||||||
|
InputKey::Tab => Some(b"\t".to_vec()),
|
||||||
|
InputKey::BackTab => Some(b"\x1b[Z".to_vec()),
|
||||||
|
InputKey::Backspace => Some(vec![0x7f]),
|
||||||
|
InputKey::Esc => Some(vec![0x1b]),
|
||||||
|
InputKey::Up => Some(b"\x1b[A".to_vec()),
|
||||||
|
InputKey::Down => Some(b"\x1b[B".to_vec()),
|
||||||
|
InputKey::Right => Some(b"\x1b[C".to_vec()),
|
||||||
|
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
||||||
|
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
||||||
|
InputKey::End => Some(b"\x1b[F".to_vec()),
|
||||||
|
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
||||||
|
InputKey::Paste(text) => Some(text.as_bytes().to_vec()),
|
||||||
|
InputKey::Mouse { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,74 +501,232 @@ mod tests {
|
|||||||
|
|
||||||
fn make_app() -> App {
|
fn make_app() -> App {
|
||||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
App::new(cfg, CatalogStore::default(), "http://127.0.0.1:7070".into())
|
App::new(
|
||||||
|
cfg,
|
||||||
|
CatalogStore::default(),
|
||||||
|
DaemonClient::new("http://127.0.0.1:7070"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn enter_insert_then_leave() {
|
fn enter_insert_then_leave() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
assert_eq!(app.state.mode, Mode::Normal);
|
assert_eq!(app.state.mode, Mode::Normal);
|
||||||
app.dispatch(Action::EnterInsert).await;
|
app.dispatch(Action::EnterInsert);
|
||||||
assert_eq!(app.state.mode, Mode::Insert);
|
assert_eq!(app.state.mode, Mode::Insert);
|
||||||
app.dispatch(Action::LeaveInsert).await;
|
app.dispatch(Action::LeaveInsert);
|
||||||
assert_eq!(app.state.mode, Mode::Normal);
|
assert_eq!(app.state.mode, Mode::Normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn new_tab_action_extends_tabs() {
|
fn new_tab_action_extends_tabs() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.dispatch(Action::NewTab).await;
|
app.dispatch(Action::NewTab);
|
||||||
app.dispatch(Action::NewTab).await;
|
app.dispatch(Action::NewTab);
|
||||||
assert_eq!(app.state.tabs.len(), 3);
|
assert_eq!(app.state.tabs.len(), 3);
|
||||||
app.dispatch(Action::CloseTab).await;
|
app.dispatch(Action::CloseTab);
|
||||||
assert_eq!(app.state.tabs.len(), 2);
|
assert_eq!(app.state.tabs.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn show_help_opens_overlay() {
|
fn show_help_opens_overlay() {
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
app.dispatch(Action::ShowHelp).await;
|
app.dispatch(Action::ShowHelp);
|
||||||
assert!(matches!(app.state.overlay, Some(OverlayKind::Help)));
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::Help)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn open_spawn_modal_seeds_daemon_list() {
|
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
||||||
use maestro_protocol::{DaemonInfo, RegistryEvent};
|
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
let d = DaemonInfo {
|
app.dispatch(Action::NewDraftPane);
|
||||||
daemon_id: "konishi".into(),
|
assert!(matches!(
|
||||||
hostname: "konishi.lan".into(),
|
app.state.active_tab().pane,
|
||||||
version: "test".into(),
|
Some(PaneRef::Draft(_))
|
||||||
daemon_url: "ws://k:7070".into(),
|
));
|
||||||
home_dir: String::new(),
|
assert_eq!(app.state.focus, Focus::Pane);
|
||||||
};
|
assert_eq!(app.state.mode, Mode::Insert);
|
||||||
app.catalog.apply_event(RegistryEvent::Snapshot {
|
assert!(app.state.overlay().is_none(), "no overlay involved");
|
||||||
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]
|
/// A tab already showing a session gets a new tab for the draft.
|
||||||
async fn kill_highlighted_opens_confirm() {
|
#[test]
|
||||||
|
fn new_draft_pane_over_a_session_opens_a_new_tab() {
|
||||||
|
let sid = Uuid::from_u128(10);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
app.dispatch(Action::AttachHighlighted);
|
||||||
|
|
||||||
|
app.dispatch(Action::NewDraftPane);
|
||||||
|
assert_eq!(app.state.tabs.len(), 2);
|
||||||
|
assert_eq!(app.state.active_tab_idx, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
app.state.active_tab().pane,
|
||||||
|
Some(PaneRef::Draft(_))
|
||||||
|
));
|
||||||
|
assert_eq!(app.state.tabs[0].session(), Some(sid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed the catalog with one session and highlight it.
|
||||||
|
fn app_with_highlighted_session(sid: Uuid) -> App {
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::{DaemonInfo, RegistryEvent, Session, SessionStatus};
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
||||||
let mut app = make_app();
|
let mut app = make_app();
|
||||||
let sid = Uuid::from_u128(7);
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
app.catalog.apply_event(RegistryEvent::Snapshot {
|
sessions: vec![Session {
|
||||||
daemons: vec![DaemonInfo {
|
id: sid,
|
||||||
daemon_id: "k".into(),
|
command: "bash".into(),
|
||||||
hostname: "k".into(),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
version: "t".into(),
|
env: Default::default(),
|
||||||
daemon_url: "ws://k".into(),
|
metadata: Default::default(),
|
||||||
home_dir: String::new(),
|
status: SessionStatus::Running,
|
||||||
}],
|
created_at: Utc::now(),
|
||||||
|
foreground: None,
|
||||||
|
activity: Default::default(),
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
current_cwd: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_highlighted_asks_for_an_attach() {
|
||||||
|
let sid = Uuid::from_u128(11);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
assert_eq!(
|
||||||
|
app.dispatch(Action::AttachHighlighted),
|
||||||
|
vec![Effect::Attach(sid)]
|
||||||
|
);
|
||||||
|
assert_eq!(app.state.active_tab().session(), Some(sid));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirming_a_kill_asks_for_the_kill() {
|
||||||
|
let sid = Uuid::from_u128(12);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
app.dispatch(Action::KillHighlighted);
|
||||||
|
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
||||||
|
c: 'y',
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
});
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert_eq!(effects, vec![Effect::Kill(sid)]);
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declining_a_kill_asks_for_nothing() {
|
||||||
|
let sid = Uuid::from_u128(13);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
app.dispatch(Action::KillHighlighted);
|
||||||
|
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.dispatch(Action::NewDraftPane);
|
||||||
|
|
||||||
|
for c in "/srv/work".chars() {
|
||||||
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
|
}
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // cwd → command
|
||||||
|
for c in "claude".chars() {
|
||||||
|
app.handle_draft_input(&InputKey::char(c));
|
||||||
|
}
|
||||||
|
app.handle_draft_input(&InputKey::Tab); // command → submit
|
||||||
|
|
||||||
|
match app.handle_draft_input(&InputKey::Enter) {
|
||||||
|
Some(Effect::Spawn(req)) => {
|
||||||
|
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
|
||||||
|
assert_eq!(req.command, "claude");
|
||||||
|
}
|
||||||
|
other => panic!("expected a Spawn effect, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys fed while no draft exists must be ignored, not panic.
|
||||||
|
#[test]
|
||||||
|
fn draft_input_is_a_no_op_without_a_draft() {
|
||||||
|
let mut app = make_app();
|
||||||
|
assert!(app.handle_draft_input(&InputKey::char('x')).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
|
||||||
|
/// failure message stayed on screen until another overlay replaced it.
|
||||||
|
#[test]
|
||||||
|
fn a_toast_is_dismissed_by_the_next_key_without_eating_it() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.state.toast("spawn failed: nope");
|
||||||
|
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
||||||
|
c: 'j',
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
decision,
|
||||||
|
OverlayDecision::Pass,
|
||||||
|
"the keystroke should still reach the keymap"
|
||||||
|
);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
assert!(app.state.overlay().is_none(), "the toast must be gone");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forwarding_is_a_no_op_without_an_attached_pane() {
|
||||||
|
let mut app = make_app();
|
||||||
|
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A draft pane is not a session: forwarding must not try to reach it.
|
||||||
|
#[test]
|
||||||
|
fn forwarding_is_a_no_op_with_a_draft_pane() {
|
||||||
|
let mut app = make_app();
|
||||||
|
app.dispatch(Action::NewDraftPane);
|
||||||
|
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
||||||
|
/// paste into an attached session vanished once bracketed paste was
|
||||||
|
/// enabled — the payload must be forwarded verbatim.
|
||||||
|
#[test]
|
||||||
|
fn a_bracketed_paste_is_forwarded_verbatim_to_the_session() {
|
||||||
|
assert_eq!(
|
||||||
|
encode_key(&InputKey::Paste("cd /tmp/foo".into())),
|
||||||
|
Some(b"cd /tmp/foo".to_vec())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_events_have_no_wire_encoding() {
|
||||||
|
use crate::input::{MouseKind, Region};
|
||||||
|
assert!(encode_key(&InputKey::Mouse {
|
||||||
|
kind: MouseKind::Click,
|
||||||
|
region: Region::Pane,
|
||||||
|
col: 3,
|
||||||
|
row: 4,
|
||||||
|
})
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kill_highlighted_opens_confirm() {
|
||||||
|
use chrono::Utc;
|
||||||
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
||||||
|
let mut app = make_app();
|
||||||
|
let sid = Uuid::from_u128(7);
|
||||||
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
sessions: vec![Session {
|
sessions: vec![Session {
|
||||||
id: sid,
|
id: sid,
|
||||||
daemon_id: "k".into(),
|
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/"),
|
cwd: std::path::PathBuf::from("/"),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
@@ -548,7 +740,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::KillHighlighted).await;
|
app.dispatch(Action::KillHighlighted);
|
||||||
assert!(matches!(app.state.overlay, Some(OverlayKind::KillConfirm(s)) if s == sid));
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
//! 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.
|
//! 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.
|
//! apply atomically and bump a monotonic version counter.
|
||||||
//! 3. On WS disconnect: exponential backoff (1s → 30s, ±25% jitter); the next
|
//! 3. On WS disconnect: exponential backoff (1s → 30s, ±25% jitter); the next
|
||||||
//! connect resends a fresh `Snapshot` so we converge without needing
|
//! connect resends a fresh `Snapshot` so we converge without needing
|
||||||
@@ -21,7 +21,7 @@ use std::sync::{Arc, RwLock};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use maestro_protocol::{DaemonInfo, RegistryEvent, Session};
|
use maestro_protocol::{CatalogEvent, Session};
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -31,7 +31,6 @@ use uuid::Uuid;
|
|||||||
/// needed.
|
/// needed.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct CatalogSnapshot {
|
pub struct CatalogSnapshot {
|
||||||
pub daemons: Vec<DaemonInfo>,
|
|
||||||
pub sessions: Vec<Session>,
|
pub sessions: Vec<Session>,
|
||||||
pub version: u64,
|
pub version: u64,
|
||||||
}
|
}
|
||||||
@@ -49,7 +48,6 @@ struct Inner {
|
|||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct State {
|
struct State {
|
||||||
daemons: HashMap<String, DaemonInfo>,
|
|
||||||
sessions: HashMap<Uuid, Session>,
|
sessions: HashMap<Uuid, Session>,
|
||||||
/// Bumped on every applied event. Wraps at u64::MAX, which is fine
|
/// Bumped on every applied event. Wraps at u64::MAX, which is fine
|
||||||
/// because comparisons are equality, not ordering.
|
/// because comparisons are equality, not ordering.
|
||||||
@@ -61,33 +59,20 @@ struct State {
|
|||||||
|
|
||||||
impl CatalogStore {
|
impl CatalogStore {
|
||||||
/// Apply one event atomically. Used by tests directly and by the WS loop.
|
/// 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");
|
let mut s = self.inner.state.write().expect("catalog rwlock poisoned");
|
||||||
match ev {
|
match ev {
|
||||||
RegistryEvent::Snapshot { daemons, sessions } => {
|
CatalogEvent::Snapshot { sessions } => {
|
||||||
s.daemons = daemons
|
|
||||||
.into_iter()
|
|
||||||
.map(|d| (d.daemon_id.clone(), d))
|
|
||||||
.collect();
|
|
||||||
s.sessions = sessions.into_iter().map(|x| (x.id, x)).collect();
|
s.sessions = sessions.into_iter().map(|x| (x.id, x)).collect();
|
||||||
s.bootstrapped = true;
|
s.bootstrapped = true;
|
||||||
}
|
}
|
||||||
RegistryEvent::DaemonAdded { daemon } => {
|
CatalogEvent::SessionAdded { session } | CatalogEvent::SessionUpdated { session } => {
|
||||||
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 } => {
|
|
||||||
s.sessions.insert(session.id, session);
|
s.sessions.insert(session.id, session);
|
||||||
}
|
}
|
||||||
RegistryEvent::SessionRemoved { daemon_id: _, id } => {
|
CatalogEvent::SessionRemoved { id } => {
|
||||||
s.sessions.remove(&id);
|
s.sessions.remove(&id);
|
||||||
}
|
}
|
||||||
RegistryEvent::Lagged { skipped } => {
|
CatalogEvent::Lagged { skipped } => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
skipped,
|
skipped,
|
||||||
"catalog events stream lagged; resync via reconnect"
|
"catalog events stream lagged; resync via reconnect"
|
||||||
@@ -101,15 +86,12 @@ impl CatalogStore {
|
|||||||
self.inner.notify.notify_waiters();
|
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 {
|
pub fn snapshot(&self) -> CatalogSnapshot {
|
||||||
let s = self.inner.state.read().expect("catalog rwlock poisoned");
|
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();
|
let mut sessions: Vec<Session> = s.sessions.values().cloned().collect();
|
||||||
sessions.sort_by_key(|x| x.id);
|
sessions.sort_by_key(|x| x.id);
|
||||||
CatalogSnapshot {
|
CatalogSnapshot {
|
||||||
daemons,
|
|
||||||
sessions,
|
sessions,
|
||||||
version: s.version,
|
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
|
/// apply them. Reconnects with exponential backoff on disconnect. Never
|
||||||
/// returns — spawn it on a Tokio task.
|
/// returns — spawn it on a Tokio task.
|
||||||
pub async fn run_events_loop(self, ws_url: String) {
|
pub async fn run_events_loop(self, ws_url: String) {
|
||||||
@@ -154,7 +136,7 @@ impl CatalogStore {
|
|||||||
while let Some(frame) = stream.next().await {
|
while let Some(frame) = stream.next().await {
|
||||||
match frame {
|
match frame {
|
||||||
Ok(Message::Text(text)) => {
|
Ok(Message::Text(text)) => {
|
||||||
match serde_json::from_str::<RegistryEvent>(&text) {
|
match serde_json::from_str::<CatalogEvent>(&text) {
|
||||||
Ok(ev) => self.apply_event(ev),
|
Ok(ev) => self.apply_event(ev),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = ?e, "catalog: bad event frame");
|
tracing::warn!(error = ?e, "catalog: bad event frame");
|
||||||
@@ -197,22 +179,11 @@ fn jitter(base: Duration) -> Duration {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
|
use maestro_protocol::{Session, SessionStatus};
|
||||||
|
|
||||||
fn daemon(id: &str) -> DaemonInfo {
|
fn session(id: u128, command: &str) -> Session {
|
||||||
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 {
|
|
||||||
Session {
|
Session {
|
||||||
id: Uuid::from_u128(id),
|
id: Uuid::from_u128(id),
|
||||||
daemon_id: daemon_id.into(),
|
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
cwd: std::path::PathBuf::from("/tmp"),
|
cwd: std::path::PathBuf::from("/tmp"),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
@@ -222,7 +193,6 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,82 +200,39 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn snapshot_event_replaces_state() {
|
fn snapshot_event_replaces_state() {
|
||||||
let store = CatalogStore::default();
|
let store = CatalogStore::default();
|
||||||
store.apply_event(RegistryEvent::Snapshot {
|
store.apply_event(CatalogEvent::Snapshot {
|
||||||
daemons: vec![daemon("konishi")],
|
sessions: vec![session(1, "a")],
|
||||||
sessions: vec![session(1, "konishi", "a")],
|
|
||||||
});
|
});
|
||||||
let snap = store.snapshot();
|
let snap = store.snapshot();
|
||||||
assert_eq!(snap.daemons.len(), 1);
|
|
||||||
assert_eq!(snap.sessions.len(), 1);
|
assert_eq!(snap.sessions.len(), 1);
|
||||||
assert_eq!(snap.version, 1);
|
assert_eq!(snap.version, 1);
|
||||||
assert!(store.is_bootstrapped());
|
assert!(store.is_bootstrapped());
|
||||||
|
|
||||||
// A second snapshot wipes out the old state.
|
// A second snapshot wipes out the old state.
|
||||||
store.apply_event(RegistryEvent::Snapshot {
|
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
|
||||||
daemons: vec![daemon("yamato")],
|
|
||||||
sessions: vec![],
|
|
||||||
});
|
|
||||||
let snap = store.snapshot();
|
let snap = store.snapshot();
|
||||||
assert_eq!(snap.daemons.len(), 1);
|
|
||||||
assert_eq!(snap.daemons[0].daemon_id, "yamato");
|
|
||||||
assert!(snap.sessions.is_empty());
|
assert!(snap.sessions.is_empty());
|
||||||
assert_eq!(snap.version, 2);
|
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]
|
#[test]
|
||||||
fn session_added_updated_removed() {
|
fn session_added_updated_removed() {
|
||||||
let store = CatalogStore::default();
|
let store = CatalogStore::default();
|
||||||
store.apply_event(RegistryEvent::Snapshot {
|
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
|
||||||
daemons: vec![daemon("konishi")],
|
store.apply_event(CatalogEvent::SessionAdded {
|
||||||
sessions: vec![],
|
session: session(7, "vim"),
|
||||||
});
|
|
||||||
store.apply_event(RegistryEvent::SessionAdded {
|
|
||||||
session: session(7, "konishi", "vim"),
|
|
||||||
});
|
});
|
||||||
assert_eq!(store.snapshot().sessions.len(), 1);
|
assert_eq!(store.snapshot().sessions.len(), 1);
|
||||||
assert_eq!(store.snapshot().sessions[0].command, "vim");
|
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;
|
updated.status = SessionStatus::Dead;
|
||||||
store.apply_event(RegistryEvent::SessionUpdated { session: updated });
|
store.apply_event(CatalogEvent::SessionUpdated { session: updated });
|
||||||
let snap = store.snapshot();
|
let snap = store.snapshot();
|
||||||
assert_eq!(snap.sessions[0].command, "claude");
|
assert_eq!(snap.sessions[0].command, "claude");
|
||||||
assert!(matches!(snap.sessions[0].status, SessionStatus::Dead));
|
assert!(matches!(snap.sessions[0].status, SessionStatus::Dead));
|
||||||
|
|
||||||
store.apply_event(RegistryEvent::SessionRemoved {
|
store.apply_event(CatalogEvent::SessionRemoved {
|
||||||
daemon_id: "konishi".into(),
|
|
||||||
id: Uuid::from_u128(7),
|
id: Uuid::from_u128(7),
|
||||||
});
|
});
|
||||||
assert!(store.snapshot().sessions.is_empty());
|
assert!(store.snapshot().sessions.is_empty());
|
||||||
@@ -314,14 +241,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn lagged_event_doesnt_blow_up_state() {
|
fn lagged_event_doesnt_blow_up_state() {
|
||||||
let store = CatalogStore::default();
|
let store = CatalogStore::default();
|
||||||
store.apply_event(RegistryEvent::Snapshot {
|
store.apply_event(CatalogEvent::Snapshot {
|
||||||
daemons: vec![daemon("konishi")],
|
sessions: vec![session(1, "a")],
|
||||||
sessions: vec![session(1, "konishi", "a")],
|
|
||||||
});
|
});
|
||||||
let before = store.snapshot();
|
let before = store.snapshot();
|
||||||
store.apply_event(RegistryEvent::Lagged { skipped: 42 });
|
store.apply_event(CatalogEvent::Lagged { skipped: 42 });
|
||||||
let after = store.snapshot();
|
let after = store.snapshot();
|
||||||
assert_eq!(after.daemons, before.daemons);
|
|
||||||
assert_eq!(after.sessions, before.sessions);
|
assert_eq!(after.sessions, before.sessions);
|
||||||
assert_eq!(after.version, before.version + 1);
|
assert_eq!(after.version, before.version + 1);
|
||||||
}
|
}
|
||||||
@@ -329,17 +254,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn snapshot_is_sorted() {
|
fn snapshot_is_sorted() {
|
||||||
let store = CatalogStore::default();
|
let store = CatalogStore::default();
|
||||||
store.apply_event(RegistryEvent::Snapshot {
|
store.apply_event(CatalogEvent::Snapshot {
|
||||||
daemons: vec![daemon("yamato"), daemon("konishi"), daemon("aurora")],
|
sessions: vec![session(3, "c"), session(1, "a"), session(2, "b")],
|
||||||
sessions: vec![
|
|
||||||
session(3, "aurora", "c"),
|
|
||||||
session(1, "konishi", "a"),
|
|
||||||
session(2, "yamato", "b"),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
let snap = store.snapshot();
|
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();
|
let session_ids: Vec<_> = snap.sessions.iter().map(|s| s.id.as_u128()).collect();
|
||||||
assert_eq!(session_ids, vec![1, 2, 3]);
|
assert_eq!(session_ids, vec![1, 2, 3]);
|
||||||
}
|
}
|
||||||
@@ -353,10 +271,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
assert!(!handle.is_finished());
|
assert!(!handle.is_finished());
|
||||||
store.apply_event(RegistryEvent::Snapshot {
|
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
|
||||||
daemons: vec![],
|
|
||||||
sessions: vec![],
|
|
||||||
});
|
|
||||||
// Give the waker a chance.
|
// Give the waker a chance.
|
||||||
tokio::time::timeout(Duration::from_millis(200), handle)
|
tokio::time::timeout(Duration::from_millis(200), handle)
|
||||||
.await
|
.await
|
||||||
@@ -372,10 +287,7 @@ mod tests {
|
|||||||
tokio::spawn(async move { s.changed().await })
|
tokio::spawn(async move { s.changed().await })
|
||||||
};
|
};
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
store.apply_event(RegistryEvent::Snapshot {
|
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
|
||||||
daemons: vec![],
|
|
||||||
sessions: vec![],
|
|
||||||
});
|
|
||||||
tokio::time::timeout(Duration::from_millis(200), waiter)
|
tokio::time::timeout(Duration::from_millis(200), waiter)
|
||||||
.await
|
.await
|
||||||
.expect("changed did not fire")
|
.expect("changed did not fire")
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("up", "SidebarPrev"),
|
("up", "SidebarPrev"),
|
||||||
// Catalog actions
|
// Catalog actions
|
||||||
("enter", "AttachHighlighted"),
|
("enter", "AttachHighlighted"),
|
||||||
("s", "OpenSpawnModal"),
|
("s", "NewDraftPane"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
// Tabs
|
// Tabs
|
||||||
("ctrl-t", "NewTab"),
|
("ctrl-t", "NewTab"),
|
||||||
@@ -155,6 +155,10 @@ mod tests {
|
|||||||
cfg.keymap.resolve(Mode::Normal, "g t"),
|
cfg.keymap.resolve(Mode::Normal, "g t"),
|
||||||
Some(Action::NextTab),
|
Some(Action::NextTab),
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||||
|
Some(Action::NewDraftPane),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! Draft pane — the spawn form living in an ordinary pane (issue #63).
|
||||||
|
//!
|
||||||
|
//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands
|
||||||
|
//! the caller a built request and the pane is replaced with the new session,
|
||||||
|
//! matching the web client's inline draft panes (#57). Field editing goes
|
||||||
|
//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields.
|
||||||
|
//!
|
||||||
|
//! Scope note: presets, the project list, generated names, and the worktree
|
||||||
|
//! section land with issues #64/#65 — this is cwd + command only.
|
||||||
|
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::input::InputKey;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DraftField {
|
||||||
|
#[default]
|
||||||
|
Cwd,
|
||||||
|
Command,
|
||||||
|
Submit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DraftPane {
|
||||||
|
pub cwd: crate::text_input::TextInput,
|
||||||
|
pub command: crate::text_input::TextInput,
|
||||||
|
pub focused_field: DraftField,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DraftInputResult {
|
||||||
|
Consumed,
|
||||||
|
Submit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DraftPane {
|
||||||
|
pub fn next_field(&mut self) {
|
||||||
|
self.focused_field = match self.focused_field {
|
||||||
|
DraftField::Cwd => DraftField::Command,
|
||||||
|
DraftField::Command => DraftField::Submit,
|
||||||
|
DraftField::Submit => DraftField::Cwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_field(&mut self) {
|
||||||
|
self.focused_field = match self.focused_field {
|
||||||
|
DraftField::Cwd => DraftField::Submit,
|
||||||
|
DraftField::Command => DraftField::Cwd,
|
||||||
|
DraftField::Submit => DraftField::Command,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle one keystroke while this draft owns input (Insert mode, pane
|
||||||
|
/// focused). Returns [`DraftInputResult::Submit`] when the user accepts;
|
||||||
|
/// the caller fires the POST and replaces the draft with the session.
|
||||||
|
pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult {
|
||||||
|
match key {
|
||||||
|
InputKey::Tab => {
|
||||||
|
self.next_field();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
|
InputKey::BackTab => {
|
||||||
|
self.prev_field();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
|
InputKey::Enter => match self.focused_field {
|
||||||
|
DraftField::Submit => DraftInputResult::Submit,
|
||||||
|
_ => {
|
||||||
|
self.next_field();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Everything else — characters, motion, editing, paste — goes to
|
||||||
|
// the focused field's text input.
|
||||||
|
other => {
|
||||||
|
match self.focused_field {
|
||||||
|
DraftField::Cwd => self.cwd.handle_key(other),
|
||||||
|
DraftField::Command => self.command.handle_key(other),
|
||||||
|
DraftField::Submit => {}
|
||||||
|
}
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw into the pane area. `pane_focused` gates the field highlight and
|
||||||
|
/// cursor placement the same way a session pane hides its cursor when
|
||||||
|
/// unfocused.
|
||||||
|
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
|
||||||
|
let block = Block::default().title(" draft ").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(1),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let style_for = |field: DraftField| {
|
||||||
|
if pane_focused && self.focused_field == field {
|
||||||
|
Style::default().add_modifier(Modifier::REVERSED)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each text field renders through the shared `TextInput`, which
|
||||||
|
// handles horizontal scrolling and reports where the terminal cursor
|
||||||
|
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
|
||||||
|
let label = |text: &str| Paragraph::new(Line::from(Span::raw(format!("{text:8} "))));
|
||||||
|
f.render_widget(label("cwd:"), rows[0]);
|
||||||
|
f.render_widget(label("command:"), rows[1]);
|
||||||
|
let draw_field =
|
||||||
|
|f: &mut Frame, input: &crate::text_input::TextInput, row: Rect, style: Style| -> u16 {
|
||||||
|
let area = Rect {
|
||||||
|
x: row.x.saturating_add(9),
|
||||||
|
y: row.y,
|
||||||
|
width: row.width.saturating_sub(9),
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
input.render(area, f.buffer_mut(), style)
|
||||||
|
};
|
||||||
|
let cwd_col = draw_field(f, &self.cwd, rows[0], style_for(DraftField::Cwd));
|
||||||
|
let cmd_col = draw_field(f, &self.command, rows[1], style_for(DraftField::Command));
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![Span::styled(
|
||||||
|
"[ Spawn ]",
|
||||||
|
style_for(DraftField::Submit),
|
||||||
|
)])),
|
||||||
|
rows[2],
|
||||||
|
);
|
||||||
|
|
||||||
|
if pane_focused {
|
||||||
|
match self.focused_field {
|
||||||
|
DraftField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
|
||||||
|
DraftField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
|
||||||
|
DraftField::Submit => {
|
||||||
|
// The submit button is indicated by the REVERSED style; no
|
||||||
|
// cursor placement.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
|
fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String {
|
||||||
|
let backend = TestBackend::new(w, h);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
terminal.draw(draw).unwrap();
|
||||||
|
let buf = terminal.backend().buffer().clone();
|
||||||
|
let mut out = String::new();
|
||||||
|
for y in 0..buf.area.height {
|
||||||
|
for x in 0..buf.area.width {
|
||||||
|
out.push_str(buf[(x, y)].symbol());
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_pane_renders_field_labels_and_values() {
|
||||||
|
let draft = DraftPane {
|
||||||
|
cwd: crate::text_input::TextInput::with_value("/x"),
|
||||||
|
command: crate::text_input::TextInput::with_value("bash"),
|
||||||
|
focused_field: DraftField::Cwd,
|
||||||
|
};
|
||||||
|
let rendered = render_to_string(80, 20, |f| draft.render(f, f.area(), true));
|
||||||
|
for s in ["cwd", "command", "draft", "Spawn", "/x", "bash"] {
|
||||||
|
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_rotation_wraps_in_both_directions() {
|
||||||
|
let mut draft = DraftPane::default();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||||
|
draft.next_field();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Command);
|
||||||
|
draft.next_field();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Submit);
|
||||||
|
draft.next_field();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||||
|
draft.prev_field();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Submit);
|
||||||
|
|
||||||
|
draft.on_input(&InputKey::BackTab);
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Command);
|
||||||
|
draft.on_input(&InputKey::Tab);
|
||||||
|
draft.on_input(&InputKey::Tab);
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Editing keys must reach the focused field's text input, not a bare
|
||||||
|
/// String — this is what gives the operator cursor motion and word ops.
|
||||||
|
#[test]
|
||||||
|
fn editing_keys_reach_the_focused_text_input() {
|
||||||
|
let mut draft = DraftPane::default();
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Cwd);
|
||||||
|
for k in [
|
||||||
|
InputKey::char('/'),
|
||||||
|
InputKey::char('t'),
|
||||||
|
InputKey::char('m'),
|
||||||
|
InputKey::char('p'),
|
||||||
|
] {
|
||||||
|
draft.on_input(&k);
|
||||||
|
}
|
||||||
|
draft.on_input(&InputKey::Left);
|
||||||
|
draft.on_input(&InputKey::Backspace);
|
||||||
|
assert_eq!(draft.cwd.value(), "/tp");
|
||||||
|
|
||||||
|
// Tab to command, then a paste and ctrl-w land there, not in cwd.
|
||||||
|
draft.on_input(&InputKey::Tab);
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Command);
|
||||||
|
draft.on_input(&InputKey::Paste("/tmp/foo".into()));
|
||||||
|
assert_eq!(draft.command.value(), "/tmp/foo");
|
||||||
|
draft.on_input(&InputKey::ctrl_char('w'));
|
||||||
|
assert_eq!(draft.command.value(), "");
|
||||||
|
assert_eq!(draft.cwd.value(), "/tp", "cwd untouched while unfocused");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter walks forward until the submit field, where it submits.
|
||||||
|
#[test]
|
||||||
|
fn enter_walks_fields_then_submits_from_submit() {
|
||||||
|
let mut draft = DraftPane::default();
|
||||||
|
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Command);
|
||||||
|
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
|
||||||
|
assert_eq!(draft.focused_field, DraftField::Submit);
|
||||||
|
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal cursor must sit right after the typed text in the focused
|
||||||
|
/// field (label is 8 chars + 1 space, block border takes column 0).
|
||||||
|
#[test]
|
||||||
|
fn render_places_the_cursor_after_the_typed_text_when_focused() {
|
||||||
|
let draft = DraftPane {
|
||||||
|
cwd: crate::text_input::TextInput::with_value("abc"),
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
let backend = TestBackend::new(80, 20);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
||||||
|
let pos = terminal.get_cursor_position().unwrap();
|
||||||
|
assert_eq!(pos, ratatui::layout::Position::new(1 + 9 + 3, 1));
|
||||||
|
|
||||||
|
// Unfocused pane: no field highlight either.
|
||||||
|
let rendered_unfocused = render_to_string(80, 20, |f| draft.render(f, f.area(), false));
|
||||||
|
let backend = TestBackend::new(80, 20);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
terminal.draw(|f| draft.render(f, f.area(), false)).unwrap();
|
||||||
|
let buf = terminal.backend().buffer();
|
||||||
|
assert!(
|
||||||
|
!buf.content()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)),
|
||||||
|
"unfocused draft must not highlight fields:\n{rendered_unfocused}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,11 +27,19 @@ pub enum InputKey {
|
|||||||
Esc,
|
Esc,
|
||||||
Enter,
|
Enter,
|
||||||
Tab,
|
Tab,
|
||||||
|
/// Shift-Tab. crossterm reports it as its own key code, not a modified Tab.
|
||||||
|
BackTab,
|
||||||
Backspace,
|
Backspace,
|
||||||
Up,
|
Up,
|
||||||
Down,
|
Down,
|
||||||
Left,
|
Left,
|
||||||
Right,
|
Right,
|
||||||
|
Home,
|
||||||
|
End,
|
||||||
|
Delete,
|
||||||
|
/// Bracketed paste: crossterm delivers the whole payload as one event, so
|
||||||
|
/// text fields can insert it as a single operation.
|
||||||
|
Paste(String),
|
||||||
Mouse {
|
Mouse {
|
||||||
kind: MouseKind,
|
kind: MouseKind,
|
||||||
region: Region,
|
region: Region,
|
||||||
@@ -54,11 +62,9 @@ pub enum MouseKind {
|
|||||||
/// NAP-280); for now only the enum is needed.
|
/// NAP-280); for now only the enum is needed.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Region {
|
pub enum Region {
|
||||||
Sidebar,
|
|
||||||
SidebarRow,
|
SidebarRow,
|
||||||
Tab,
|
Tab,
|
||||||
Pane,
|
Pane,
|
||||||
SplitDivider,
|
|
||||||
Modal,
|
Modal,
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
@@ -120,11 +126,18 @@ impl InputKey {
|
|||||||
InputKey::Esc => "esc".into(),
|
InputKey::Esc => "esc".into(),
|
||||||
InputKey::Enter => "enter".into(),
|
InputKey::Enter => "enter".into(),
|
||||||
InputKey::Tab => "tab".into(),
|
InputKey::Tab => "tab".into(),
|
||||||
|
InputKey::BackTab => "backtab".into(),
|
||||||
InputKey::Backspace => "backspace".into(),
|
InputKey::Backspace => "backspace".into(),
|
||||||
InputKey::Up => "up".into(),
|
InputKey::Up => "up".into(),
|
||||||
InputKey::Down => "down".into(),
|
InputKey::Down => "down".into(),
|
||||||
InputKey::Left => "left".into(),
|
InputKey::Left => "left".into(),
|
||||||
InputKey::Right => "right".into(),
|
InputKey::Right => "right".into(),
|
||||||
|
InputKey::Home => "home".into(),
|
||||||
|
InputKey::End => "end".into(),
|
||||||
|
InputKey::Delete => "delete".into(),
|
||||||
|
// Pastes are not bindable; the canonical form exists only to
|
||||||
|
// satisfy exhaustiveness and never round-trips through `parse`.
|
||||||
|
InputKey::Paste(_) => "paste".into(),
|
||||||
InputKey::Mouse { kind, region, .. } => {
|
InputKey::Mouse { kind, region, .. } => {
|
||||||
let k = match kind {
|
let k = match kind {
|
||||||
MouseKind::Click => "click",
|
MouseKind::Click => "click",
|
||||||
@@ -134,11 +147,9 @@ impl InputKey {
|
|||||||
MouseKind::ScrollDown => "scrolldown",
|
MouseKind::ScrollDown => "scrolldown",
|
||||||
};
|
};
|
||||||
let r = match region {
|
let r = match region {
|
||||||
Region::Sidebar => "sidebar",
|
|
||||||
Region::SidebarRow => "sidebar-row",
|
Region::SidebarRow => "sidebar-row",
|
||||||
Region::Tab => "tab",
|
Region::Tab => "tab",
|
||||||
Region::Pane => "pane",
|
Region::Pane => "pane",
|
||||||
Region::SplitDivider => "split-divider",
|
|
||||||
Region::Modal => "modal",
|
Region::Modal => "modal",
|
||||||
Region::Unknown => "unknown",
|
Region::Unknown => "unknown",
|
||||||
};
|
};
|
||||||
@@ -168,11 +179,9 @@ impl InputKey {
|
|||||||
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
other => return Err(InputParseError::UnknownMouseKind(other.into())),
|
||||||
};
|
};
|
||||||
let region = match r {
|
let region = match r {
|
||||||
"sidebar" => Region::Sidebar,
|
|
||||||
"sidebar-row" => Region::SidebarRow,
|
"sidebar-row" => Region::SidebarRow,
|
||||||
"tab" => Region::Tab,
|
"tab" => Region::Tab,
|
||||||
"pane" => Region::Pane,
|
"pane" => Region::Pane,
|
||||||
"split-divider" => Region::SplitDivider,
|
|
||||||
"modal" => Region::Modal,
|
"modal" => Region::Modal,
|
||||||
"unknown" => Region::Unknown,
|
"unknown" => Region::Unknown,
|
||||||
other => return Err(InputParseError::UnknownRegion(other.into())),
|
other => return Err(InputParseError::UnknownRegion(other.into())),
|
||||||
@@ -188,11 +197,15 @@ impl InputKey {
|
|||||||
"esc" => return Ok(InputKey::Esc),
|
"esc" => return Ok(InputKey::Esc),
|
||||||
"enter" => return Ok(InputKey::Enter),
|
"enter" => return Ok(InputKey::Enter),
|
||||||
"tab" => return Ok(InputKey::Tab),
|
"tab" => return Ok(InputKey::Tab),
|
||||||
|
"backtab" => return Ok(InputKey::BackTab),
|
||||||
"backspace" => return Ok(InputKey::Backspace),
|
"backspace" => return Ok(InputKey::Backspace),
|
||||||
"up" => return Ok(InputKey::Up),
|
"up" => return Ok(InputKey::Up),
|
||||||
"down" => return Ok(InputKey::Down),
|
"down" => return Ok(InputKey::Down),
|
||||||
"left" => return Ok(InputKey::Left),
|
"left" => return Ok(InputKey::Left),
|
||||||
"right" => return Ok(InputKey::Right),
|
"right" => return Ok(InputKey::Right),
|
||||||
|
"home" => return Ok(InputKey::Home),
|
||||||
|
"end" => return Ok(InputKey::End),
|
||||||
|
"delete" => return Ok(InputKey::Delete),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
// Modifier chain: ctrl-, alt-, shift- prefixes, then a single char.
|
// Modifier chain: ctrl-, alt-, shift- prefixes, then a single char.
|
||||||
@@ -258,14 +271,19 @@ where
|
|||||||
KeyCode::Esc => InputKey::Esc,
|
KeyCode::Esc => InputKey::Esc,
|
||||||
KeyCode::Enter => InputKey::Enter,
|
KeyCode::Enter => InputKey::Enter,
|
||||||
KeyCode::Tab => InputKey::Tab,
|
KeyCode::Tab => InputKey::Tab,
|
||||||
|
KeyCode::BackTab => InputKey::BackTab,
|
||||||
KeyCode::Backspace => InputKey::Backspace,
|
KeyCode::Backspace => InputKey::Backspace,
|
||||||
KeyCode::Up => InputKey::Up,
|
KeyCode::Up => InputKey::Up,
|
||||||
KeyCode::Down => InputKey::Down,
|
KeyCode::Down => InputKey::Down,
|
||||||
KeyCode::Left => InputKey::Left,
|
KeyCode::Left => InputKey::Left,
|
||||||
KeyCode::Right => InputKey::Right,
|
KeyCode::Right => InputKey::Right,
|
||||||
|
KeyCode::Home => InputKey::Home,
|
||||||
|
KeyCode::End => InputKey::End,
|
||||||
|
KeyCode::Delete => InputKey::Delete,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
CtEvent::Paste(text) => Some(InputKey::Paste(text.clone())),
|
||||||
CtEvent::Mouse(MouseEvent {
|
CtEvent::Mouse(MouseEvent {
|
||||||
kind, column, row, ..
|
kind, column, row, ..
|
||||||
}) => {
|
}) => {
|
||||||
@@ -288,6 +306,63 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long after a click a second click at the same cell counts as a
|
||||||
|
/// double-click. Matches the common desktop default.
|
||||||
|
pub const DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(400);
|
||||||
|
|
||||||
|
/// Remembers the last click so a second one at the same cell can be promoted to
|
||||||
|
/// [`MouseKind::DblClick`].
|
||||||
|
///
|
||||||
|
/// Kept out of [`canonicalise`], which stays a pure function of one event.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ClickTracker {
|
||||||
|
last: Option<(u16, u16, Instant)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClickTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upgrade a `Click` to a `DblClick` when it *immediately* follows another
|
||||||
|
/// click at the same cell inside [`DOUBLE_CLICK_WINDOW`].
|
||||||
|
///
|
||||||
|
/// Anything else passes through and clears the pending click: two clicks
|
||||||
|
/// with a drag or a keystroke between them are two clicks, however fast.
|
||||||
|
///
|
||||||
|
/// A promoted click also resets the tracker, so three rapid clicks are a
|
||||||
|
/// click then a double-click, not two double-clicks.
|
||||||
|
pub fn promote(&mut self, key: InputKey, now: Instant) -> InputKey {
|
||||||
|
let InputKey::Mouse {
|
||||||
|
kind: MouseKind::Click,
|
||||||
|
region,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
} = key
|
||||||
|
else {
|
||||||
|
self.last = None;
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_double = matches!(
|
||||||
|
self.last,
|
||||||
|
Some((c, r, at))
|
||||||
|
if c == col && r == row && now.duration_since(at) <= DOUBLE_CLICK_WINDOW
|
||||||
|
);
|
||||||
|
if is_double {
|
||||||
|
self.last = None;
|
||||||
|
return InputKey::Mouse {
|
||||||
|
kind: MouseKind::DblClick,
|
||||||
|
region,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
self.last = Some((col, row, now));
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of feeding one key into [`ChordState`]. `Pending` = held because the
|
/// Result of feeding one key into [`ChordState`]. `Pending` = held because the
|
||||||
/// current sequence is a strict prefix of some known binding; `Resolved` =
|
/// current sequence is a strict prefix of some known binding; `Resolved` =
|
||||||
/// dispatch this canonical string against the keymap.
|
/// dispatch this canonical string against the keymap.
|
||||||
@@ -358,6 +433,151 @@ impl ChordState {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn click(col: u16, row: u16) -> InputKey {
|
||||||
|
InputKey::Mouse {
|
||||||
|
kind: MouseKind::Click,
|
||||||
|
region: Region::SidebarRow,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shipped default binds `mouse:dblclick(sidebar-row)`. Nothing could
|
||||||
|
/// produce a DblClick, so that binding could never fire.
|
||||||
|
#[test]
|
||||||
|
fn a_second_click_at_the_same_cell_becomes_a_double_click() {
|
||||||
|
let mut t = ClickTracker::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
|
||||||
|
assert_eq!(t.promote(click(3, 4), t0), click(3, 4));
|
||||||
|
assert_eq!(
|
||||||
|
t.promote(click(3, 4), t0 + Duration::from_millis(100)),
|
||||||
|
InputKey::Mouse {
|
||||||
|
kind: MouseKind::DblClick,
|
||||||
|
region: Region::SidebarRow,
|
||||||
|
col: 3,
|
||||||
|
row: 4,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_slow_second_click_stays_a_click() {
|
||||||
|
let mut t = ClickTracker::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
t.promote(click(3, 4), t0);
|
||||||
|
let late = t0 + DOUBLE_CLICK_WINDOW + Duration::from_millis(1);
|
||||||
|
assert_eq!(t.promote(click(3, 4), late), click(3, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_click_elsewhere_stays_a_click() {
|
||||||
|
let mut t = ClickTracker::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
t.promote(click(3, 4), t0);
|
||||||
|
assert_eq!(
|
||||||
|
t.promote(click(9, 4), t0 + Duration::from_millis(10)),
|
||||||
|
click(9, 4)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three fast clicks are click, double-click, click — not two doubles.
|
||||||
|
#[test]
|
||||||
|
fn a_promoted_click_resets_the_tracker() {
|
||||||
|
let mut t = ClickTracker::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
t.promote(click(1, 1), t0);
|
||||||
|
let second = t.promote(click(1, 1), t0 + Duration::from_millis(50));
|
||||||
|
assert!(matches!(
|
||||||
|
second,
|
||||||
|
InputKey::Mouse {
|
||||||
|
kind: MouseKind::DblClick,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let third = t.promote(click(1, 1), t0 + Duration::from_millis(100));
|
||||||
|
assert_eq!(third, click(1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_click_events_pass_through_untouched() {
|
||||||
|
let mut t = ClickTracker::new();
|
||||||
|
assert_eq!(t.promote(InputKey::Enter, Instant::now()), InputKey::Enter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drag between two clicks means the user dragged, not double-clicked —
|
||||||
|
/// even at the same cell and well inside the window.
|
||||||
|
#[test]
|
||||||
|
fn a_drag_between_two_clicks_breaks_the_double() {
|
||||||
|
let mut t = ClickTracker::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
t.promote(click(3, 4), t0);
|
||||||
|
|
||||||
|
let drag = InputKey::Mouse {
|
||||||
|
kind: MouseKind::Drag,
|
||||||
|
region: Region::SidebarRow,
|
||||||
|
col: 3,
|
||||||
|
row: 4,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
t.promote(drag.clone(), t0 + Duration::from_millis(20)),
|
||||||
|
drag
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
t.promote(click(3, 4), t0 + Duration::from_millis(40)),
|
||||||
|
click(3, 4),
|
||||||
|
"a drag must clear the pending click"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_keystroke_between_two_clicks_breaks_the_double() {
|
||||||
|
let mut t = ClickTracker::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
t.promote(click(3, 4), t0);
|
||||||
|
t.promote(InputKey::Enter, t0 + Duration::from_millis(20));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
t.promote(click(3, 4), t0 + Duration::from_millis(40)),
|
||||||
|
click(3, 4)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scroll also counts as an intervening event.
|
||||||
|
#[test]
|
||||||
|
fn a_scroll_between_two_clicks_breaks_the_double() {
|
||||||
|
let mut t = ClickTracker::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
t.promote(click(3, 4), t0);
|
||||||
|
let scroll = InputKey::Mouse {
|
||||||
|
kind: MouseKind::ScrollUp,
|
||||||
|
region: Region::SidebarRow,
|
||||||
|
col: 3,
|
||||||
|
row: 4,
|
||||||
|
};
|
||||||
|
t.promote(scroll, t0 + Duration::from_millis(20));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
t.promote(click(3, 4), t0 + Duration::from_millis(40)),
|
||||||
|
click(3, 4)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn back_tab_round_trips_through_the_canonical_form() {
|
||||||
|
assert_eq!(InputKey::BackTab.canonical(), "backtab");
|
||||||
|
assert_eq!(InputKey::parse("backtab").unwrap(), InputKey::BackTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parser must not accept region names the renderer cannot produce.
|
||||||
|
#[test]
|
||||||
|
fn unproducible_region_names_are_rejected() {
|
||||||
|
assert!(InputKey::parse("mouse:click(sidebar)").is_err());
|
||||||
|
assert!(InputKey::parse("mouse:click(split-divider)").is_err());
|
||||||
|
assert!(InputKey::parse("mouse:click(sidebar-row)").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_round_trips_simple_chars() {
|
fn canonical_round_trips_simple_chars() {
|
||||||
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
|
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
|
||||||
@@ -377,12 +597,25 @@ mod tests {
|
|||||||
"down",
|
"down",
|
||||||
"left",
|
"left",
|
||||||
"right",
|
"right",
|
||||||
|
"home",
|
||||||
|
"end",
|
||||||
|
"delete",
|
||||||
] {
|
] {
|
||||||
let k = InputKey::parse(s).unwrap();
|
let k = InputKey::parse(s).unwrap();
|
||||||
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A bracketed paste must arrive as one key carrying the whole payload,
|
||||||
|
/// not a burst of per-character keys.
|
||||||
|
#[test]
|
||||||
|
fn canonicalise_paste_event_carries_the_whole_payload() {
|
||||||
|
use crossterm::event::Event as CtEvent;
|
||||||
|
let ev = CtEvent::Paste("/srv/work/maestro".into());
|
||||||
|
let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap();
|
||||||
|
assert_eq!(k, InputKey::Paste("/srv/work/maestro".into()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_round_trips_mouse() {
|
fn canonical_round_trips_mouse() {
|
||||||
let s = "mouse:click(pane)";
|
let s = "mouse:click(pane)";
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
//! `maestro tui` — terminal UI client over the local daemon's `/api/*` surface.
|
//! `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)]
|
#![deny(missing_debug_implementations)]
|
||||||
|
|
||||||
@@ -10,6 +7,7 @@ pub mod app;
|
|||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod draft;
|
||||||
pub mod emulators;
|
pub mod emulators;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
@@ -21,10 +19,12 @@ pub mod sidebar;
|
|||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
pub mod text_input;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use maestro_protocol::DaemonClient;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
|
|
||||||
@@ -40,14 +40,14 @@ pub struct RunConfig {
|
|||||||
/// No trailing slash.
|
/// No trailing slash.
|
||||||
pub daemon_url: String,
|
pub daemon_url: String,
|
||||||
/// Explicit `--config <path>` override from the umbrella binary's global
|
/// Explicit `--config <path>` override from the umbrella binary's global
|
||||||
/// flag. When `None`, [`maestro_config::resolve_config_path`] picks the
|
/// flag. When `None`, [`maestro_config::default_config_path`] picks the
|
||||||
/// default (`$MAESTRO_CONFIG` or `~/.config/maestro/config.toml`).
|
/// default (`$MAESTRO_CONFIG` or `~/.config/maestro/config.toml`).
|
||||||
pub config_path: Option<PathBuf>,
|
pub config_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum TuiError {
|
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),
|
DaemonUnreachable(String),
|
||||||
#[error("config: {0}")]
|
#[error("config: {0}")]
|
||||||
Config(String),
|
Config(String),
|
||||||
@@ -65,23 +65,32 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
|||||||
let base_url = cfg.daemon_url.trim_end_matches('/').to_string();
|
let base_url = cfg.daemon_url.trim_end_matches('/').to_string();
|
||||||
|
|
||||||
// Health check — fail fast if no daemon is reachable.
|
// Health check — fail fast if no daemon is reachable.
|
||||||
health_check(&base_url).await?;
|
health_check(&DaemonClient::with_timeouts(
|
||||||
|
&base_url,
|
||||||
|
HEALTH_TIMEOUT,
|
||||||
|
HEALTH_TIMEOUT,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let daemon = DaemonClient::with_timeouts(&base_url, REQUEST_TIMEOUT, REQUEST_TIMEOUT);
|
||||||
|
|
||||||
// Load runtime config: explicit `--config <path>` wins, then
|
// Load runtime config: explicit `--config <path>` wins, then
|
||||||
// `$MAESTRO_CONFIG`, then the platform default. Missing file is fine —
|
// `$MAESTRO_CONFIG`, then the platform default. Missing file is fine —
|
||||||
// we fall back to shipped defaults.
|
// we fall back to shipped defaults.
|
||||||
let cfg_path = cfg.config_path.clone().map(Ok).unwrap_or_else(|| {
|
let cfg_path = cfg.config_path.clone().map(Ok).unwrap_or_else(|| {
|
||||||
maestro_config::resolve_config_path().map_err(|e| TuiError::Config(e.to_string()))
|
maestro_config::default_config_path().map_err(|e| TuiError::Config(e.to_string()))
|
||||||
});
|
});
|
||||||
let cfg_file = match cfg_path {
|
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()))?,
|
.map_err(|e| TuiError::Config(e.to_string()))?,
|
||||||
Err(_) => maestro_config::MaestroConfigFile::default(),
|
Err(_) => maestro_config::MaestroConfigFile::default(),
|
||||||
};
|
};
|
||||||
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
|
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
|
||||||
|
|
||||||
let catalog = CatalogStore::default();
|
let catalog = CatalogStore::default();
|
||||||
let ws_url = format!("{}/api/events", ws_base(&base_url));
|
let ws_url = daemon
|
||||||
|
.events_url()
|
||||||
|
.map_err(|e| TuiError::Config(e.to_string()))?;
|
||||||
let bg_catalog = catalog.clone();
|
let bg_catalog = catalog.clone();
|
||||||
tokio::spawn(async move { bg_catalog.run_events_loop(ws_url).await });
|
tokio::spawn(async move { bg_catalog.run_events_loop(ws_url).await });
|
||||||
|
|
||||||
@@ -95,33 +104,32 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
|||||||
let backend = CrosstermBackend::new(std::io::stdout());
|
let backend = CrosstermBackend::new(std::io::stdout());
|
||||||
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
|
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
|
||||||
|
|
||||||
let app = App::new(tui_cfg, catalog, base_url);
|
let app = App::new(tui_cfg, catalog, daemon);
|
||||||
app.run(&mut terminal).await
|
app.run(&mut terminal).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health_check(base_url: &str) -> Result<(), TuiError> {
|
/// Startup probe budget, applied to connect *and* the whole request: the user is
|
||||||
let client = reqwest::Client::builder()
|
/// staring at a terminal, so an unreachable daemon must fail in under a second.
|
||||||
.timeout(Duration::from_millis(500))
|
const HEALTH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
.build()
|
|
||||||
.expect("reqwest client");
|
|
||||||
let url = format!("{base_url}/api/web/info");
|
|
||||||
for _ in 0..3 {
|
|
||||||
if let Ok(res) = client.get(&url).send().await {
|
|
||||||
if res.status().is_success() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
||||||
}
|
|
||||||
Err(TuiError::DaemonUnreachable(base_url.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ws_base(http: &str) -> String {
|
/// Budget for the TUI's own calls once it is up. Tighter than the client
|
||||||
if let Some(rest) = http.strip_prefix("http://") {
|
/// default — a spawn or kill that stalls should surface as a toast, not hang
|
||||||
format!("ws://{rest}")
|
/// the interface.
|
||||||
} else if let Some(rest) = http.strip_prefix("https://") {
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
format!("wss://{rest}")
|
|
||||||
} else {
|
async fn health_check(daemon: &DaemonClient) -> Result<(), TuiError> {
|
||||||
http.to_string()
|
for _ in 0..3 {
|
||||||
|
if let Ok(info) = daemon.daemon_info().await {
|
||||||
|
if !maestro_protocol::is_compatible(&info.version) {
|
||||||
|
tracing::warn!(
|
||||||
|
daemon = %info.version,
|
||||||
|
client = %maestro_protocol::PROTOCOL_VERSION,
|
||||||
|
"daemon protocol version differs from this client's",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(HEALTH_TIMEOUT).await;
|
||||||
}
|
}
|
||||||
|
Err(TuiError::DaemonUnreachable(daemon.base().to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,189 +1,16 @@
|
|||||||
//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay,
|
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
|
||||||
//! transient toast.
|
//!
|
||||||
|
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
|
||||||
|
//! panes ([`crate::draft::DraftPane`]).
|
||||||
//!
|
//!
|
||||||
//! All overlays are centered popups drawn over the rest of the UI; the
|
//! All overlays are centered popups drawn over the rest of the UI; the
|
||||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||||
|
|
||||||
use maestro_protocol::DaemonInfo;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
|
||||||
use ratatui::style::{Modifier, Style};
|
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SpawnField {
|
|
||||||
#[default]
|
|
||||||
Daemon,
|
|
||||||
Cwd,
|
|
||||||
Command,
|
|
||||||
Submit,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev_field(&mut self) {
|
|
||||||
self.focused_field = match self.focused_field {
|
|
||||||
SpawnField::Daemon => SpawnField::Submit,
|
|
||||||
SpawnField::Cwd => SpawnField::Daemon,
|
|
||||||
SpawnField::Command => SpawnField::Cwd,
|
|
||||||
SpawnField::Submit => SpawnField::Command,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle one keystroke while this modal owns input. Returns
|
|
||||||
/// [`SpawnInputResult::Submit`] when the user accepts; the caller fires
|
|
||||||
/// the POST and dismisses the modal.
|
|
||||||
pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult {
|
|
||||||
use crate::input::InputKey;
|
|
||||||
match key {
|
|
||||||
InputKey::Tab => {
|
|
||||||
self.next_field();
|
|
||||||
SpawnInputResult::Consumed
|
|
||||||
}
|
|
||||||
InputKey::Esc => SpawnInputResult::Cancel,
|
|
||||||
InputKey::Enter => match self.focused_field {
|
|
||||||
SpawnField::Submit => SpawnInputResult::Submit,
|
|
||||||
_ => {
|
|
||||||
self.next_field();
|
|
||||||
SpawnInputResult::Consumed
|
|
||||||
}
|
|
||||||
},
|
|
||||||
InputKey::Char { c, .. } => {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
InputKey::Backspace => {
|
|
||||||
match self.focused_field {
|
|
||||||
SpawnField::Cwd => {
|
|
||||||
self.cwd.pop();
|
|
||||||
}
|
|
||||||
SpawnField::Command => {
|
|
||||||
self.command.pop();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
SpawnInputResult::Consumed
|
|
||||||
}
|
|
||||||
_ => SpawnInputResult::Consumed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
|
||||||
let area = centered(parent, 60, 12);
|
|
||||||
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),
|
|
||||||
])
|
|
||||||
.split(inner);
|
|
||||||
let highlight = |field: SpawnField| {
|
|
||||||
if self.focused_field == field {
|
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
|
||||||
} else {
|
|
||||||
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} ")),
|
|
||||||
ratatui::text::Span::styled(value.to_string(), style),
|
|
||||||
]))
|
|
||||||
};
|
|
||||||
f.render_widget(
|
|
||||||
field_row("daemon:", daemon_label, highlight(SpawnField::Daemon)),
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
f.render_widget(
|
|
||||||
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
|
|
||||||
"[ Spawn ]",
|
|
||||||
highlight(SpawnField::Submit),
|
|
||||||
)])),
|
|
||||||
rows[3],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Place the cursor at the end of the currently-focused text field so
|
|
||||||
// the user can see where their typing lands. Label is fixed-width
|
|
||||||
// 8 chars + 1 space = column 9 inside the row.
|
|
||||||
let cursor_col_for = |value: &str| -> u16 {
|
|
||||||
inner
|
|
||||||
.x
|
|
||||||
.saturating_add(9)
|
|
||||||
.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::Command => {
|
|
||||||
f.set_cursor_position((cursor_col_for(&self.command), rows[2].y))
|
|
||||||
}
|
|
||||||
SpawnField::Daemon | SpawnField::Submit => {
|
|
||||||
// Non-text fields are indicated by the REVERSED style; no
|
|
||||||
// cursor placement.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum SpawnInputResult {
|
|
||||||
Consumed,
|
|
||||||
Cancel,
|
|
||||||
Submit,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct KillConfirm {
|
pub struct KillConfirm {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -290,43 +117,6 @@ mod tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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"] {
|
|
||||||
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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);
|
|
||||||
modal.prev_field();
|
|
||||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn kill_confirm_renders_prompt() {
|
fn kill_confirm_renders_prompt() {
|
||||||
let modal = KillConfirm {
|
let modal = KillConfirm {
|
||||||
@@ -344,11 +134,11 @@ mod tests {
|
|||||||
let help = HelpOverlay {
|
let help = HelpOverlay {
|
||||||
lines: vec![
|
lines: vec![
|
||||||
"i EnterInsert".into(),
|
"i EnterInsert".into(),
|
||||||
"s OpenSpawnModal".into(),
|
"s NewDraftPane".into(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
||||||
assert!(rendered.contains("EnterInsert"));
|
assert!(rendered.contains("EnterInsert"));
|
||||||
assert!(rendered.contains("OpenSpawnModal"));
|
assert!(rendered.contains("NewDraftPane"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use ratatui::Frame;
|
|||||||
|
|
||||||
use crate::input::Region;
|
use crate::input::Region;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
|
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind};
|
use crate::state::{AppState, Focus, OverlayKind};
|
||||||
@@ -63,8 +63,9 @@ pub struct RenderInputs<'a> {
|
|||||||
pub sidebar: &'a Sidebar,
|
pub sidebar: &'a Sidebar,
|
||||||
pub tab_bar: &'a TabBar,
|
pub tab_bar: &'a TabBar,
|
||||||
pub pane: Option<&'a Pane>,
|
pub pane: Option<&'a Pane>,
|
||||||
|
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
||||||
|
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||||
pub state: &'a AppState,
|
pub state: &'a AppState,
|
||||||
pub spawn: &'a SpawnModal,
|
|
||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,17 +132,16 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
if let Some(p) = inputs.pane {
|
if let Some(p) = inputs.pane {
|
||||||
p.render(f, pane_area);
|
p.render(f, pane_area);
|
||||||
}
|
}
|
||||||
|
if let Some(d) = inputs.draft {
|
||||||
|
d.render(f, pane_area, pane_focused);
|
||||||
|
}
|
||||||
map.pane = Some(pane_area);
|
map.pane = Some(pane_area);
|
||||||
|
|
||||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||||
// always see the current mode + key hints.
|
// always see the current mode + key hints.
|
||||||
render_status_bar(f, status_area, inputs.state);
|
render_status_bar(f, status_area, inputs.state);
|
||||||
|
|
||||||
match &inputs.state.overlay {
|
match inputs.state.overlay() {
|
||||||
Some(OverlayKind::Spawn) => {
|
|
||||||
inputs.spawn.render(f, area);
|
|
||||||
map.modal = Some(area);
|
|
||||||
}
|
|
||||||
Some(OverlayKind::KillConfirm(id)) => {
|
Some(OverlayKind::KillConfirm(id)) => {
|
||||||
let kc = KillConfirm {
|
let kc = KillConfirm {
|
||||||
label: id.to_string(),
|
label: id.to_string(),
|
||||||
@@ -186,7 +186,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
Focus::Pane => "pane",
|
Focus::Pane => "pane",
|
||||||
};
|
};
|
||||||
let hint = match state.mode {
|
let hint = match state.mode {
|
||||||
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
|
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||||
Mode::Insert => "^a: back to normal",
|
Mode::Insert => "^a: back to normal",
|
||||||
};
|
};
|
||||||
let mode_style = match state.mode {
|
let mode_style = match state.mode {
|
||||||
@@ -266,4 +266,57 @@ mod tests {
|
|||||||
assert_eq!(map.region_at(15, 10), Region::Modal);
|
assert_eq!(map.region_at(15, 10), Region::Modal);
|
||||||
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
||||||
|
/// form where the terminal would otherwise be.
|
||||||
|
#[test]
|
||||||
|
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
|
||||||
|
use crate::overlays::HelpOverlay;
|
||||||
|
use crate::state::PaneRef;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.open_draft_pane();
|
||||||
|
let draft = match &state.tabs[0].pane {
|
||||||
|
Some(PaneRef::Draft(d)) => d,
|
||||||
|
other => panic!("expected a draft pane, got {other:?}"),
|
||||||
|
};
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: Vec::new(),
|
||||||
|
highlight: None,
|
||||||
|
attached: HashSet::new(),
|
||||||
|
};
|
||||||
|
let tab_bar = TabBar {
|
||||||
|
tab_names: vec!["1".into()],
|
||||||
|
active_idx: 0,
|
||||||
|
};
|
||||||
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
|
let inputs = RenderInputs {
|
||||||
|
sidebar: &sidebar,
|
||||||
|
tab_bar: &tab_bar,
|
||||||
|
pane: None,
|
||||||
|
draft: Some(draft),
|
||||||
|
state: &state,
|
||||||
|
help: &help,
|
||||||
|
};
|
||||||
|
|
||||||
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|f| {
|
||||||
|
draw(f, inputs);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let buf = terminal.backend().buffer().clone();
|
||||||
|
let mut out = String::new();
|
||||||
|
for y in 0..buf.area.height {
|
||||||
|
for x in 0..buf.area.width {
|
||||||
|
out.push_str(buf[(x, y)].symbol());
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
for s in ["draft", "cwd:", "command:", "[ Spawn ]"] {
|
||||||
|
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! Sidebar widget — daemon→session tree, navigation-only.
|
//! Sidebar widget — session list, navigation-only.
|
||||||
//!
|
//!
|
||||||
//! Status glyphs: ● attached / ○ live / ✗ dead.
|
//! Status glyphs: ● attached / ○ live / ✗ dead.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
|
use maestro_protocol::{Session, SessionStatus};
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
@@ -15,7 +15,6 @@ use uuid::Uuid;
|
|||||||
/// Renderable sidebar state.
|
/// Renderable sidebar state.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
pub daemons: Vec<DaemonInfo>,
|
|
||||||
pub sessions: Vec<Session>,
|
pub sessions: Vec<Session>,
|
||||||
pub highlight: Option<Uuid>,
|
pub highlight: Option<Uuid>,
|
||||||
pub attached: HashSet<Uuid>,
|
pub attached: HashSet<Uuid>,
|
||||||
@@ -43,28 +42,25 @@ impl Sidebar {
|
|||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
|
|
||||||
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
||||||
"daemons",
|
"sessions",
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
))];
|
))];
|
||||||
for d in &self.daemons {
|
for s in &self.sessions {
|
||||||
lines.push(Line::from(format!("● {}", d.hostname)));
|
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||||
for s in self.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
|
(SessionStatus::Dead, _) => "✗",
|
||||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
(SessionStatus::Running, true) => "●",
|
||||||
(SessionStatus::Dead, _) => "✗",
|
(SessionStatus::Running, false) => "○",
|
||||||
(SessionStatus::Running, true) => "●",
|
};
|
||||||
(SessionStatus::Running, false) => "○",
|
let label = name_for(s);
|
||||||
};
|
let style = if self.highlight == Some(s.id) {
|
||||||
let label = name_for(s);
|
Style::default().add_modifier(Modifier::REVERSED)
|
||||||
let style = if self.highlight == Some(s.id) {
|
} else {
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
Style::default()
|
||||||
} else {
|
};
|
||||||
Style::default()
|
lines.push(Line::from(vec![
|
||||||
};
|
Span::raw(" "),
|
||||||
lines.push(Line::from(vec![
|
Span::styled(format!("{glyph} {label}"), style),
|
||||||
Span::raw(" "),
|
]));
|
||||||
Span::styled(format!("{glyph} {label}"), style),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
f.render_widget(Paragraph::new(lines), inner);
|
f.render_widget(Paragraph::new(lines), inner);
|
||||||
}
|
}
|
||||||
@@ -87,22 +83,11 @@ mod tests {
|
|||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
fn daemon(id: &str) -> DaemonInfo {
|
fn session_named(id: u128, name: &str) -> Session {
|
||||||
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 {
|
|
||||||
let mut metadata = HashMap::new();
|
let mut metadata = HashMap::new();
|
||||||
metadata.insert("name".into(), serde_json::Value::String(name.into()));
|
metadata.insert("name".into(), serde_json::Value::String(name.into()));
|
||||||
Session {
|
Session {
|
||||||
id: Uuid::from_u128(id),
|
id: Uuid::from_u128(id),
|
||||||
daemon_id: daemon_id.into(),
|
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
cwd: std::path::PathBuf::from("/tmp"),
|
cwd: std::path::PathBuf::from("/tmp"),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
@@ -132,21 +117,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_hostname_and_session_names() {
|
fn renders_session_names() {
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
daemons: vec![daemon("konishi")],
|
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
||||||
sessions: vec![
|
|
||||||
session_named(1, "konishi", "claude"),
|
|
||||||
session_named(2, "konishi", "bash"),
|
|
||||||
],
|
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let rendered = render_to_string(&sb, 28, 12);
|
let rendered = render_to_string(&sb, 28, 12);
|
||||||
assert!(
|
|
||||||
rendered.contains("konishi"),
|
|
||||||
"missing daemon name:\n{rendered}"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("claude"),
|
rendered.contains("claude"),
|
||||||
"missing session name:\n{rendered}"
|
"missing session name:\n{rendered}"
|
||||||
@@ -161,8 +138,7 @@ mod tests {
|
|||||||
fn attached_session_uses_filled_glyph() {
|
fn attached_session_uses_filled_glyph() {
|
||||||
let sid = Uuid::from_u128(1);
|
let sid = Uuid::from_u128(1);
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
daemons: vec![daemon("konishi")],
|
sessions: vec![session_named(1, "claude")],
|
||||||
sessions: vec![session_named(1, "konishi", "claude")],
|
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: [sid].into_iter().collect(),
|
attached: [sid].into_iter().collect(),
|
||||||
};
|
};
|
||||||
@@ -180,10 +156,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dead_session_uses_cross_glyph() {
|
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;
|
sess.status = SessionStatus::Dead;
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
daemons: vec![daemon("konishi")],
|
|
||||||
sessions: vec![sess],
|
sessions: vec![sess],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
@@ -200,7 +175,6 @@ mod tests {
|
|||||||
fn falls_back_to_command_when_name_metadata_is_absent() {
|
fn falls_back_to_command_when_name_metadata_is_absent() {
|
||||||
let s = Session {
|
let s = Session {
|
||||||
id: Uuid::from_u128(1),
|
id: Uuid::from_u128(1),
|
||||||
daemon_id: "konishi".into(),
|
|
||||||
command: "vim".into(),
|
command: "vim".into(),
|
||||||
cwd: std::path::PathBuf::from("/tmp"),
|
cwd: std::path::PathBuf::from("/tmp"),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
@@ -214,7 +188,6 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
};
|
};
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
daemons: vec![daemon("konishi")],
|
|
||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
|
|||||||
+176
-12
@@ -1,14 +1,16 @@
|
|||||||
//! `AppState` — the top-level mutable state of the running TUI.
|
//! `AppState` — the top-level mutable state of the running TUI.
|
||||||
//!
|
//!
|
||||||
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
|
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
|
||||||
//! session), an active tab index, focus split between sidebar and pane, the
|
//! session or a draft form), an active tab index, focus split between sidebar
|
||||||
//! current input mode, and an optional overlay (spawn modal, kill confirm,
|
//! and pane, the current input mode, and an optional overlay (kill confirm,
|
||||||
//! help, transient toast).
|
//! help, transient toast). The spawn form is no longer an overlay — an empty
|
||||||
|
//! pane is a draft pane (issue #63).
|
||||||
//!
|
//!
|
||||||
//! v1.5 will replace `Tab::pane: Option<SessionId>` with a split tree.
|
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::draft::DraftPane;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -17,15 +19,31 @@ pub enum Focus {
|
|||||||
Pane,
|
Pane,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum PaneRef {
|
||||||
|
Session(Uuid),
|
||||||
|
Draft(DraftPane),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Tab {
|
pub struct Tab {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub pane: Option<Uuid>,
|
pub pane: Option<PaneRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tab {
|
||||||
|
/// The session this tab shows, if it shows one. Draft panes have none.
|
||||||
|
pub fn session(&self) -> Option<Uuid> {
|
||||||
|
match &self.pane {
|
||||||
|
Some(PaneRef::Session(sid)) => Some(*sid),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum OverlayKind {
|
pub enum OverlayKind {
|
||||||
Spawn,
|
|
||||||
KillConfirm(Uuid),
|
KillConfirm(Uuid),
|
||||||
Help,
|
Help,
|
||||||
Toast(String),
|
Toast(String),
|
||||||
@@ -39,7 +57,11 @@ pub struct AppState {
|
|||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub sidebar_visible: bool,
|
pub sidebar_visible: bool,
|
||||||
pub sidebar_highlight: Option<Uuid>,
|
pub sidebar_highlight: Option<Uuid>,
|
||||||
pub overlay: Option<OverlayKind>,
|
/// Private: every transition goes through [`AppState::show_overlay`],
|
||||||
|
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
|
||||||
|
/// from four call sites is what left a toast on screen with no way to
|
||||||
|
/// clear it.
|
||||||
|
overlay: Option<OverlayKind>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppState {
|
impl Default for AppState {
|
||||||
@@ -64,6 +86,24 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn overlay(&self) -> Option<&OverlayKind> {
|
||||||
|
self.overlay.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_overlay(&mut self, kind: OverlayKind) {
|
||||||
|
self.overlay = Some(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear whatever overlay is up. Returns true if one was showing.
|
||||||
|
pub fn dismiss_overlay(&mut self) -> bool {
|
||||||
|
self.overlay.take().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Surface a transient message. Replaces any overlay currently up.
|
||||||
|
pub fn toast(&mut self, message: impl Into<String>) {
|
||||||
|
self.overlay = Some(OverlayKind::Toast(message.into()));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn active_tab(&self) -> &Tab {
|
pub fn active_tab(&self) -> &Tab {
|
||||||
&self.tabs[self.active_tab_idx]
|
&self.tabs[self.active_tab_idx]
|
||||||
}
|
}
|
||||||
@@ -83,14 +123,23 @@ impl AppState {
|
|||||||
|
|
||||||
/// Drop the active tab. No-op if it's the only one — there must always
|
/// Drop the active tab. No-op if it's the only one — there must always
|
||||||
/// be at least one tab.
|
/// be at least one tab.
|
||||||
pub fn close_tab(&mut self) {
|
///
|
||||||
|
/// Returns the session that tab was showing, if any, so the caller can
|
||||||
|
/// release its emulator. Without that the tab's WebSocket task and vt100
|
||||||
|
/// parser outlive the tab forever. A draft pane holds no session and
|
||||||
|
/// releases nothing.
|
||||||
|
pub fn close_tab(&mut self) -> Option<Uuid> {
|
||||||
if self.tabs.len() <= 1 {
|
if self.tabs.len() <= 1 {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
self.tabs.remove(self.active_tab_idx);
|
let closed = self.tabs.remove(self.active_tab_idx);
|
||||||
if self.active_tab_idx >= self.tabs.len() {
|
if self.active_tab_idx >= self.tabs.len() {
|
||||||
self.active_tab_idx = self.tabs.len() - 1;
|
self.active_tab_idx = self.tabs.len() - 1;
|
||||||
}
|
}
|
||||||
|
let sid = closed.session()?;
|
||||||
|
// Only release it if no other tab still shows the same session.
|
||||||
|
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
|
||||||
|
(!still_shown).then_some(sid)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_tab(&mut self) {
|
pub fn next_tab(&mut self) {
|
||||||
@@ -118,11 +167,37 @@ impl AppState {
|
|||||||
/// drop straight into Insert mode so keystrokes go to the session
|
/// drop straight into Insert mode so keystrokes go to the session
|
||||||
/// without an extra `i` press. The leader returns to Normal.
|
/// without an extra `i` press. The leader returns to Normal.
|
||||||
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
||||||
self.active_tab_mut().pane = Some(session_id);
|
self.active_tab_mut().pane = Some(PaneRef::Session(session_id));
|
||||||
self.focus = Focus::Pane;
|
self.focus = Focus::Pane;
|
||||||
self.mode = Mode::Insert;
|
self.mode = Mode::Insert;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
|
||||||
|
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
|
||||||
|
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
|
||||||
|
/// starts immediately, same flow as [`Self::attach_to_focused`].
|
||||||
|
pub fn open_draft_pane(&mut self) {
|
||||||
|
if self.active_tab().pane.is_some() {
|
||||||
|
self.new_tab();
|
||||||
|
}
|
||||||
|
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
||||||
|
self.focus = Focus::Pane;
|
||||||
|
self.mode = Mode::Insert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A draft submit succeeded: replace whatever the active tab showed with
|
||||||
|
/// the new session in place, matching the web client's behaviour (#57).
|
||||||
|
pub fn complete_spawn(&mut self, sid: Uuid) {
|
||||||
|
self.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||||
|
self.focus = Focus::Pane;
|
||||||
|
self.mode = Mode::Insert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session shown by the active tab's pane, if any.
|
||||||
|
pub fn active_session(&self) -> Option<Uuid> {
|
||||||
|
self.active_tab().session()
|
||||||
|
}
|
||||||
|
|
||||||
/// Move the sidebar highlight by `delta` (positive = down) within the
|
/// Move the sidebar highlight by `delta` (positive = down) within the
|
||||||
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
||||||
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
||||||
@@ -259,7 +334,7 @@ mod tests {
|
|||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
let sid = Uuid::from_u128(7);
|
let sid = Uuid::from_u128(7);
|
||||||
state.attach_to_focused(sid);
|
state.attach_to_focused(sid);
|
||||||
assert_eq!(state.tabs[0].pane, Some(sid));
|
assert_eq!(state.tabs[0].session(), Some(sid));
|
||||||
assert_eq!(state.focus, Focus::Pane);
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
// The user expects to type into the session right away; the leader
|
// The user expects to type into the session right away; the leader
|
||||||
// (default ctrl-a) is the way back.
|
// (default ctrl-a) is the way back.
|
||||||
@@ -277,4 +352,93 @@ mod tests {
|
|||||||
// Doesn't force focus back; sidebar shown but focus stays where it was.
|
// Doesn't force focus back; sidebar shown but focus stays where it was.
|
||||||
assert_eq!(state.focus, Focus::Pane);
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The emulator leak: a closed tab's session must come back so the caller
|
||||||
|
/// can abort its WebSocket task.
|
||||||
|
#[test]
|
||||||
|
fn close_tab_reports_the_session_it_dropped() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let sid = Uuid::from_u128(3);
|
||||||
|
state.new_tab();
|
||||||
|
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||||
|
|
||||||
|
assert_eq!(state.close_tab(), Some(sid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ...but not while another tab still shows it.
|
||||||
|
#[test]
|
||||||
|
fn close_tab_keeps_a_session_another_tab_still_shows() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let sid = Uuid::from_u128(4);
|
||||||
|
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||||
|
state.new_tab();
|
||||||
|
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||||
|
|
||||||
|
assert_eq!(state.close_tab(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_tab_is_a_no_op_on_the_last_tab() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
|
||||||
|
assert_eq!(state.close_tab(), None);
|
||||||
|
assert_eq!(state.tabs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dismiss_overlay_reports_whether_one_was_showing() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
assert!(!state.dismiss_overlay());
|
||||||
|
state.toast("boom");
|
||||||
|
assert!(state.dismiss_overlay());
|
||||||
|
assert!(state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #63: `s` over an empty pane turns that pane into a draft —
|
||||||
|
/// focused, Insert mode on, typing starts immediately.
|
||||||
|
#[test]
|
||||||
|
fn open_draft_reuses_the_empty_active_pane() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.open_draft_pane();
|
||||||
|
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||||
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
|
assert_eq!(state.mode, Mode::Insert);
|
||||||
|
assert_eq!(state.tabs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tab already showing a session gets a fresh tab for the draft; v1
|
||||||
|
/// has no split tree, so a new tab is the "split".
|
||||||
|
#[test]
|
||||||
|
fn open_draft_over_a_session_opens_a_new_tab() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.attach_to_focused(Uuid::from_u128(9));
|
||||||
|
state.open_draft_pane();
|
||||||
|
assert_eq!(state.tabs.len(), 2);
|
||||||
|
assert_eq!(state.active_tab_idx, 1);
|
||||||
|
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||||
|
// The session tab is untouched.
|
||||||
|
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web parity (#57): submitting fills the pane in place.
|
||||||
|
#[test]
|
||||||
|
fn complete_spawn_replaces_the_draft_with_the_session() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.open_draft_pane();
|
||||||
|
let sid = Uuid::from_u128(21);
|
||||||
|
state.complete_spawn(sid);
|
||||||
|
assert_eq!(state.active_tab().session(), Some(sid));
|
||||||
|
assert_eq!(state.focus, Focus::Pane);
|
||||||
|
assert_eq!(state.mode, Mode::Insert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A closed draft tab holds no session, so nothing is released.
|
||||||
|
#[test]
|
||||||
|
fn close_tab_reports_nothing_for_a_draft_pane() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.open_draft_pane();
|
||||||
|
state.new_tab();
|
||||||
|
state.active_tab_idx = 0;
|
||||||
|
assert_eq!(state.close_tab(), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
|
use crossterm::event::{
|
||||||
|
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||||
|
};
|
||||||
use crossterm::execute;
|
use crossterm::execute;
|
||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
@@ -26,7 +28,12 @@ impl TerminalGuard {
|
|||||||
pub fn enter() -> Result<Self, TerminalError> {
|
pub fn enter() -> Result<Self, TerminalError> {
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut out = io::stdout();
|
let mut out = io::stdout();
|
||||||
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
|
execute!(
|
||||||
|
out,
|
||||||
|
EnterAlternateScreen,
|
||||||
|
EnableMouseCapture,
|
||||||
|
EnableBracketedPaste
|
||||||
|
)?;
|
||||||
Ok(TerminalGuard)
|
Ok(TerminalGuard)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +41,12 @@ impl TerminalGuard {
|
|||||||
/// call it without producing weird errors.
|
/// call it without producing weird errors.
|
||||||
fn restore() {
|
fn restore() {
|
||||||
let mut out = io::stdout();
|
let mut out = io::stdout();
|
||||||
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
|
let _ = execute!(
|
||||||
|
out,
|
||||||
|
DisableBracketedPaste,
|
||||||
|
DisableMouseCapture,
|
||||||
|
LeaveAlternateScreen
|
||||||
|
);
|
||||||
let _ = disable_raw_mode();
|
let _ = disable_raw_mode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
//! Single-line text-input primitive shared by every TUI form field.
|
||||||
|
//!
|
||||||
|
//! Owns a `String`, a character-indexed cursor, and horizontal scroll state.
|
||||||
|
//! All editing goes through [`TextInput::handle_key`] so callers feed the same
|
||||||
|
//! canonical [`InputKey`] stream the rest of the app uses; bracketed paste
|
||||||
|
//! arrives as one [`InputKey::Paste`] and is inserted as a single operation.
|
||||||
|
//!
|
||||||
|
//! This is the one piece of the TUI form layer that unit-tests without a
|
||||||
|
//! terminal: drive it with `InputKey` sequences and inspect value/cursor.
|
||||||
|
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
|
||||||
|
use crate::input::InputKey;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TextInput {
|
||||||
|
value: String,
|
||||||
|
/// Cursor position as a char offset into `value`.
|
||||||
|
cursor: usize,
|
||||||
|
/// Char offset of the leftmost visible cell. A `Cell` so `render` can
|
||||||
|
/// re-clamp it without `&mut self` — widgets are drawn through shared
|
||||||
|
/// references in this codebase.
|
||||||
|
scroll: std::cell::Cell<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextInput {
|
||||||
|
pub fn with_value(value: impl Into<String>) -> Self {
|
||||||
|
let mut input = Self {
|
||||||
|
value: value.into(),
|
||||||
|
..Self::default()
|
||||||
|
};
|
||||||
|
input.cursor = input.value.chars().count();
|
||||||
|
input
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
&self.value
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cursor(&self) -> usize {
|
||||||
|
self.cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Char offset of the leftmost visible cell; only meaningful after
|
||||||
|
/// [`TextInput::render`] has clamped it to the field width.
|
||||||
|
pub fn scroll_offset(&self) -> usize {
|
||||||
|
self.scroll.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert `text` at the cursor as one operation. Used both for typed
|
||||||
|
/// characters and for whole bracketed-paste payloads.
|
||||||
|
pub fn insert(&mut self, text: &str) {
|
||||||
|
let at = self.byte_offset(self.cursor);
|
||||||
|
self.value.insert_str(at, text);
|
||||||
|
self.cursor += text.chars().count();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(&mut self, key: &InputKey) {
|
||||||
|
match key {
|
||||||
|
InputKey::Char {
|
||||||
|
c,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
..
|
||||||
|
} => self.insert(&c.to_string()),
|
||||||
|
InputKey::Char {
|
||||||
|
c: 'w', ctrl: true, ..
|
||||||
|
} => self.delete_word_backwards(),
|
||||||
|
InputKey::Char {
|
||||||
|
c: 'k', ctrl: true, ..
|
||||||
|
} => self.kill_to_end(),
|
||||||
|
// Emacs-style word motion; ctrl-arrow reaches us as a plain
|
||||||
|
// Left/Right today, so these are the dependable binding.
|
||||||
|
InputKey::Char {
|
||||||
|
c: 'b', ctrl: true, ..
|
||||||
|
} => self.cursor = self.word_left(),
|
||||||
|
InputKey::Char {
|
||||||
|
c: 'f', ctrl: true, ..
|
||||||
|
} => self.cursor = self.word_right(),
|
||||||
|
InputKey::Left => {
|
||||||
|
self.cursor = self.cursor.saturating_sub(1);
|
||||||
|
}
|
||||||
|
InputKey::Right => {
|
||||||
|
if self.cursor < self.value.chars().count() {
|
||||||
|
self.cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputKey::Home => self.cursor = 0,
|
||||||
|
InputKey::End => self.cursor = self.value.chars().count(),
|
||||||
|
InputKey::Backspace => {
|
||||||
|
if self.cursor > 0 {
|
||||||
|
let at = self.byte_offset(self.cursor - 1);
|
||||||
|
let end = self.byte_offset(self.cursor);
|
||||||
|
self.value.replace_range(at..end, "");
|
||||||
|
self.cursor -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputKey::Delete => {
|
||||||
|
if self.cursor < self.value.chars().count() {
|
||||||
|
let at = self.byte_offset(self.cursor);
|
||||||
|
let end = self.byte_offset(self.cursor + 1);
|
||||||
|
self.value.replace_range(at..end, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputKey::Paste(text) => self.insert(text),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw into `area` (expected one row tall), scrolling horizontally so the
|
||||||
|
/// cursor stays visible, and return the screen column where the caller
|
||||||
|
/// should place the terminal cursor.
|
||||||
|
pub fn render(&self, area: Rect, buf: &mut Buffer, style: Style) -> u16 {
|
||||||
|
let width = area.width as usize;
|
||||||
|
let len = self.value.chars().count();
|
||||||
|
let scroll = self.scroll.get().min(len);
|
||||||
|
let scroll = if self.cursor < scroll {
|
||||||
|
self.cursor
|
||||||
|
} else if width > 0 && self.cursor.saturating_sub(scroll) >= width {
|
||||||
|
self.cursor + 1 - width
|
||||||
|
} else {
|
||||||
|
scroll
|
||||||
|
};
|
||||||
|
self.scroll.set(scroll);
|
||||||
|
for (col, c) in self.value.chars().skip(scroll).take(width).enumerate() {
|
||||||
|
buf[(area.x + col as u16, area.y)]
|
||||||
|
.set_symbol(&c.to_string())
|
||||||
|
.set_style(style);
|
||||||
|
}
|
||||||
|
area.x + self.cursor.saturating_sub(scroll) as u16
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_offset(&self, char_index: usize) -> usize {
|
||||||
|
self.value
|
||||||
|
.char_indices()
|
||||||
|
.nth(char_index)
|
||||||
|
.map(|(b, _)| b)
|
||||||
|
.unwrap_or(self.value.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_word_char(c: char) -> bool {
|
||||||
|
c.is_alphanumeric() || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start of the word before the cursor, skipping any separators first —
|
||||||
|
/// emacs `backward-word`.
|
||||||
|
fn word_left(&self) -> usize {
|
||||||
|
let chars: Vec<char> = self.value.chars().collect();
|
||||||
|
let mut i = self.cursor;
|
||||||
|
while i > 0 && !Self::is_word_char(chars[i - 1]) {
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
while i > 0 && Self::is_word_char(chars[i - 1]) {
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start of the word after the cursor, skipping the rest of the current
|
||||||
|
/// word first — emacs `forward-word`.
|
||||||
|
fn word_right(&self) -> usize {
|
||||||
|
let chars: Vec<char> = self.value.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
let mut i = self.cursor;
|
||||||
|
while i < len && !Self::is_word_char(chars[i]) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
while i < len && Self::is_word_char(chars[i]) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kill_to_end(&mut self) {
|
||||||
|
let at = self.byte_offset(self.cursor);
|
||||||
|
self.value.truncate(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell-style ctrl-w: kill back to the previous whitespace, taking any
|
||||||
|
/// trailing whitespace run with it — so `cd /tmp/foo` loses the whole
|
||||||
|
/// path segment in one keystroke.
|
||||||
|
fn delete_word_backwards(&mut self) {
|
||||||
|
let chars: Vec<char> = self.value.chars().collect();
|
||||||
|
let mut i = self.cursor;
|
||||||
|
while i > 0 && chars[i - 1].is_whitespace() {
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
while i > 0 && !chars[i - 1].is_whitespace() {
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
let at = self.byte_offset(i);
|
||||||
|
let end = self.byte_offset(self.cursor);
|
||||||
|
self.value.replace_range(at..end, "");
|
||||||
|
self.cursor = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use InputKey::{Backspace, Delete, End, Home, Left, Right};
|
||||||
|
|
||||||
|
fn typed(s: &str) -> Vec<InputKey> {
|
||||||
|
s.chars().map(InputKey::char).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fed(keys: &[InputKey]) -> TextInput {
|
||||||
|
let mut input = TextInput::default();
|
||||||
|
for k in keys {
|
||||||
|
input.handle_key(k);
|
||||||
|
}
|
||||||
|
input
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ctrl(c: char) -> InputKey {
|
||||||
|
InputKey::ctrl_char(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_lands_in_order() {
|
||||||
|
let input = fed(&typed("hello"));
|
||||||
|
assert_eq!(input.value(), "hello");
|
||||||
|
assert_eq!(input.cursor(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_removes_the_last_typed_char() {
|
||||||
|
let mut input = fed(&typed("hello"));
|
||||||
|
input.handle_key(&Backspace);
|
||||||
|
assert_eq!(input.value(), "hell");
|
||||||
|
assert_eq!(input.cursor(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn left_then_insert_fixes_a_middle_typo() {
|
||||||
|
let mut input = fed(&typed("helo"));
|
||||||
|
input.handle_key(&Left);
|
||||||
|
input.handle_key(&Left);
|
||||||
|
input.handle_key(&InputKey::char('l'));
|
||||||
|
assert_eq!(input.value(), "hello");
|
||||||
|
assert_eq!(input.cursor(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn motion_keys_stop_at_the_edges() {
|
||||||
|
let mut input = fed(&typed("abc"));
|
||||||
|
for _ in 0..5 {
|
||||||
|
input.handle_key(&Left);
|
||||||
|
}
|
||||||
|
assert_eq!(input.cursor(), 0);
|
||||||
|
input.handle_key(&Left);
|
||||||
|
assert_eq!(input.cursor(), 0);
|
||||||
|
for _ in 0..5 {
|
||||||
|
input.handle_key(&Right);
|
||||||
|
}
|
||||||
|
assert_eq!(input.cursor(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_and_end_jump_to_the_ends() {
|
||||||
|
let mut input = fed(&typed("abcdef"));
|
||||||
|
input.handle_key(&Home);
|
||||||
|
assert_eq!(input.cursor(), 0);
|
||||||
|
input.handle_key(&InputKey::char('x'));
|
||||||
|
assert_eq!(input.value(), "xabcdef");
|
||||||
|
input.handle_key(&End);
|
||||||
|
input.handle_key(&InputKey::char('!'));
|
||||||
|
assert_eq!(input.value(), "xabcdef!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cursor starts after the final `/`; word-left lands on word starts,
|
||||||
|
/// never inside a separator run.
|
||||||
|
#[test]
|
||||||
|
fn ctrl_b_walks_left_one_word_at_a_time() {
|
||||||
|
let mut input = TextInput::with_value("/tmp/foo bar-baz/");
|
||||||
|
assert_eq!(input.cursor(), 17);
|
||||||
|
for expected in [13, 9, 5, 1, 0, 0] {
|
||||||
|
input.handle_key(&ctrl('b'));
|
||||||
|
assert_eq!(input.cursor(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_f_walks_right_one_word_at_a_time() {
|
||||||
|
let mut input = TextInput::with_value("/tmp/foo bar");
|
||||||
|
input.handle_key(&Home);
|
||||||
|
for expected in [4, 8, 12, 12] {
|
||||||
|
input.handle_key(&ctrl('f'));
|
||||||
|
assert_eq!(input.cursor(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_deletes_before_the_cursor() {
|
||||||
|
let mut input = fed(&typed("abcdef"));
|
||||||
|
input.handle_key(&Left);
|
||||||
|
input.handle_key(&Left);
|
||||||
|
input.handle_key(&Left);
|
||||||
|
input.handle_key(&Backspace);
|
||||||
|
assert_eq!(input.value(), "abdef");
|
||||||
|
assert_eq!(input.cursor(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_on_empty_input_is_a_noop() {
|
||||||
|
let mut input = TextInput::default();
|
||||||
|
input.handle_key(&Backspace);
|
||||||
|
assert_eq!(input.value(), "");
|
||||||
|
assert_eq!(input.cursor(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_removes_the_char_under_the_cursor() {
|
||||||
|
let mut input = fed(&typed("abcdef"));
|
||||||
|
input.handle_key(&Home);
|
||||||
|
input.handle_key(&Delete);
|
||||||
|
assert_eq!(input.value(), "bcdef");
|
||||||
|
assert_eq!(input.cursor(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_past_the_end_is_a_noop() {
|
||||||
|
let mut input = TextInput::with_value("abc");
|
||||||
|
input.handle_key(&End);
|
||||||
|
input.handle_key(&Delete);
|
||||||
|
assert_eq!(input.value(), "abc");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cd /tmp/foo` with ctrl-w leaves `cd ` — the whole path segment is one
|
||||||
|
/// word to a shell (readline kills back to whitespace, spaces included).
|
||||||
|
#[test]
|
||||||
|
fn ctrl_w_deletes_the_word_behind_the_cursor() {
|
||||||
|
let mut input = fed(&[typed("cd ").as_slice(), typed("/tmp/foo").as_slice()].concat());
|
||||||
|
input.handle_key(&ctrl('w'));
|
||||||
|
assert_eq!(input.value(), "cd ");
|
||||||
|
assert_eq!(input.cursor(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_w_stops_before_the_separator_run() {
|
||||||
|
let mut input = TextInput::with_value("a b");
|
||||||
|
input.cursor = 5;
|
||||||
|
input.handle_key(&ctrl('w'));
|
||||||
|
assert_eq!(input.value(), "a ");
|
||||||
|
assert_eq!(input.cursor(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_k_kills_everything_after_the_cursor() {
|
||||||
|
let mut input = fed(&typed("spawn --debug extra"));
|
||||||
|
for _ in 0..14 {
|
||||||
|
input.handle_key(&Left);
|
||||||
|
}
|
||||||
|
input.handle_key(&ctrl('k'));
|
||||||
|
assert_eq!(input.value(), "spawn");
|
||||||
|
assert_eq!(input.cursor(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A paste arrives as one event carrying the whole payload and must land
|
||||||
|
/// at the cursor as a single insertion, not per-character.
|
||||||
|
#[test]
|
||||||
|
fn paste_inserts_whole_payload_at_the_cursor() {
|
||||||
|
let mut input = fed(&typed("cd "));
|
||||||
|
input.handle_key(&InputKey::Paste("/srv/work".into()));
|
||||||
|
assert_eq!(input.value(), "cd /srv/work");
|
||||||
|
assert_eq!(input.cursor(), 12);
|
||||||
|
// What follows still behaves like ordinary typed text.
|
||||||
|
input.handle_key(&InputKey::char('/'));
|
||||||
|
assert_eq!(input.value(), "cd /srv/work/");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paste_between_existing_text_keeps_both_halves() {
|
||||||
|
let mut input = TextInput::with_value("ac");
|
||||||
|
input.handle_key(&Left);
|
||||||
|
input.handle_key(&InputKey::Paste("b×".into()));
|
||||||
|
assert_eq!(input.value(), "ab×c");
|
||||||
|
assert_eq!(input.cursor(), 3, "cursor counts chars, not bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rendering_an_empty_field_points_at_its_first_column() {
|
||||||
|
let input = TextInput::default();
|
||||||
|
let area = Rect::new(10, 5, 20, 1);
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
let col = input.render(area, &mut buf, Style::default());
|
||||||
|
assert_eq!(col, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rendering_reports_the_visible_cursor_column() {
|
||||||
|
let mut input = TextInput::with_value("hello");
|
||||||
|
input.handle_key(&Left);
|
||||||
|
input.handle_key(&Left);
|
||||||
|
let area = Rect::new(10, 5, 20, 1);
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
let col = input.render(area, &mut buf, Style::default());
|
||||||
|
assert_eq!(col, 13);
|
||||||
|
for (i, expected) in ["h", "e", "l", "l", "o"].iter().enumerate() {
|
||||||
|
assert_eq!(buf[(10 + i as u16, 5)].symbol(), *expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rendering_a_value_wider_than_the_field_shows_its_tail() {
|
||||||
|
let value = "0123456789abcdefghij";
|
||||||
|
let input = TextInput::with_value(value);
|
||||||
|
let area = Rect::new(0, 0, 10, 1);
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
let col = input.render(area, &mut buf, Style::default());
|
||||||
|
assert_eq!(col, 9, "cursor sits on the last visible column");
|
||||||
|
for (i, expected) in value.chars().skip(input.scroll_offset()).enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
buf[(i as u16, 0)].symbol(),
|
||||||
|
expected.to_string(),
|
||||||
|
"cell {i}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moving left into the hidden region must scroll the window back so the
|
||||||
|
/// cursor never disappears off the left edge.
|
||||||
|
#[test]
|
||||||
|
fn moving_left_into_the_hidden_region_scrolls_back() {
|
||||||
|
let value = "0123456789abcdefghij";
|
||||||
|
let mut input = TextInput::with_value(value);
|
||||||
|
let area = Rect::new(0, 0, 10, 1);
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
input.render(area, &mut buf, Style::default());
|
||||||
|
assert_eq!(input.cursor(), 20);
|
||||||
|
|
||||||
|
input.handle_key(&Home);
|
||||||
|
for _ in 0..5 {
|
||||||
|
input.handle_key(&Right);
|
||||||
|
}
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
let col = input.render(area, &mut buf, Style::default());
|
||||||
|
assert_eq!(input.cursor(), 5);
|
||||||
|
assert_eq!(
|
||||||
|
input.value().chars().nth(input.scroll_offset()),
|
||||||
|
Some('5'),
|
||||||
|
"window scrolled back to the cursor"
|
||||||
|
);
|
||||||
|
assert_eq!(col, 0);
|
||||||
|
assert_eq!(buf[(0, 0)].symbol(), "5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shrinking_the_value_clamps_scroll_and_cursor() {
|
||||||
|
let mut input = TextInput::with_value("0123456789abcdefghij");
|
||||||
|
input.handle_key(&Home);
|
||||||
|
input.handle_key(&ctrl('k'));
|
||||||
|
let area = Rect::new(0, 0, 10, 1);
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
let col = input.render(area, &mut buf, Style::default());
|
||||||
|
assert_eq!(input.value(), "");
|
||||||
|
assert_eq!(col, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,100 +1,42 @@
|
|||||||
//! WS round-trip test for `CatalogStore::run_events_loop`. Spins a tiny axum
|
//! `CatalogStore::run_events_loop` against a daemon serving the real
|
||||||
//! server that emulates the daemon's `/api/events` contract: send a `Snapshot`
|
//! `/api/events` contract, rather than a scripted socket.
|
||||||
//! on connect, then a delta.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::extract::ws::{Message, WebSocketUpgrade};
|
use maestro_testing::{session, MockDaemon};
|
||||||
use axum::response::Response;
|
|
||||||
use axum::routing::any;
|
|
||||||
use axum::Router;
|
|
||||||
use chrono::Utc;
|
|
||||||
use maestro_protocol::{DaemonInfo, RegistryEvent, Session, SessionStatus};
|
|
||||||
use maestro_tui::catalog::CatalogStore;
|
use maestro_tui::catalog::CatalogStore;
|
||||||
use uuid::Uuid;
|
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 {
|
|
||||||
Session {
|
|
||||||
id: Uuid::from_u128(id),
|
|
||||||
daemon_id: daemon_id.into(),
|
|
||||||
command: "bash".into(),
|
|
||||||
cwd: std::path::PathBuf::from("/tmp"),
|
|
||||||
env: Default::default(),
|
|
||||||
metadata: Default::default(),
|
|
||||||
status: SessionStatus::Running,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
foreground: None,
|
|
||||||
activity: Default::default(),
|
|
||||||
last_activity_at: chrono::Utc::now(),
|
|
||||||
|
|
||||||
current_cwd: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 text = serde_json::to_string(&added).unwrap();
|
|
||||||
let _ = socket.send(Message::Text(text)).await;
|
|
||||||
// Keep the connection alive so the client doesn't enter reconnect.
|
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn run_events_loop_applies_snapshot_then_delta() {
|
async fn applies_the_snapshot_then_live_deltas() {
|
||||||
let app = Router::new().route("/api/events", any(events_handler));
|
let mock = MockDaemon::start().await;
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
mock.add_session(session(Uuid::from_u128(1), "/home/mock", "bash"));
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
let ws_url = mock.client().events_url().expect("events url");
|
||||||
let store = CatalogStore::default();
|
let store = CatalogStore::default();
|
||||||
let bg = store.clone();
|
let bg = store.clone();
|
||||||
let ws_url = format!("ws://{addr}/api/events");
|
|
||||||
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
|
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
|
||||||
|
|
||||||
// Bootstrap should land within ~250 ms.
|
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
|
||||||
tokio::time::timeout(
|
.await
|
||||||
Duration::from_millis(500),
|
.expect("initial snapshot");
|
||||||
store.wait_for_initial_snapshot(),
|
assert_eq!(store.snapshot().sessions.len(), 1);
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("initial snapshot never arrived");
|
|
||||||
|
|
||||||
let snap = store.snapshot();
|
mock.add_session(session(Uuid::from_u128(2), "/home/mock", "claude"));
|
||||||
assert_eq!(snap.daemons.len(), 1);
|
wait_until(|| store.snapshot().sessions.len() == 2).await;
|
||||||
assert_eq!(snap.daemons[0].daemon_id, "konishi");
|
|
||||||
|
|
||||||
// Delta arrives shortly after. Poll for up to 500 ms.
|
mock.remove_session(Uuid::from_u128(1));
|
||||||
for _ in 0..50 {
|
wait_until(|| store.snapshot().sessions.len() == 1).await;
|
||||||
if store.snapshot().sessions.len() == 1 {
|
assert_eq!(store.snapshot().sessions[0].id, Uuid::from_u128(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_until(mut cond: impl FnMut() -> bool) {
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
if cond() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
}
|
}
|
||||||
panic!("delta never propagated; got {:?}", store.snapshot());
|
panic!("timed out waiting for the catalog to converge");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ dirs.workspace = true
|
|||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
include_dir.workspace = true
|
include_dir.workspace = true
|
||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
|
maestro-protocol.workspace = true
|
||||||
mime_guess.workspace = true
|
mime_guess.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
||||||
@@ -31,6 +33,8 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
maestro-testing.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ The binary reads `~/.config/maestro/config.toml`. The `[web]` section:
|
|||||||
[web]
|
[web]
|
||||||
bind_addr = "127.0.0.1:8080" # default
|
bind_addr = "127.0.0.1:8080" # default
|
||||||
daemon_url = "http://127.0.0.1:7070" # default
|
daemon_url = "http://127.0.0.1:7070" # default
|
||||||
# dev_proxy_target = "http://127.0.0.1:5173" # only when developing the frontend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Env-var overrides (all optional):
|
Env-var overrides (all optional):
|
||||||
@@ -79,5 +78,5 @@ pnpm dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
Vite serves on `:5173` and proxies `/api/*` straight to `maestrod` at
|
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.
|
rebuild the Rust binary while iterating on the frontend.
|
||||||
|
|||||||
+51
-26
@@ -1,16 +1,53 @@
|
|||||||
//! Build the embedded SPA bundle before the crate compiles.
|
//! Resolve the SPA bundle that gets embedded at compile time.
|
||||||
//!
|
//!
|
||||||
//! `static_bundle.rs` embeds `web/dist` at compile time via `include_dir!`.
|
//! `static_bundle.rs` embeds a directory via `include_dir!`, and the directory
|
||||||
//! This script guarantees that directory holds a freshly built bundle by
|
//! it reads is whatever this script puts in `MAESTRO_WEB_BUNDLE_DIR`.
|
||||||
//! running `pnpm install --frozen-lockfile && pnpm build` in `web/`. pnpm and
|
//!
|
||||||
//! node are HARD requirements — a missing toolchain fails the build with an
|
//! Two modes:
|
||||||
//! actionable message rather than silently shipping the placeholder.
|
//!
|
||||||
|
//! * `MAESTRO_WEB_DIST` set — the bundle was built elsewhere (Nix, CI, a
|
||||||
|
//! release pipeline) and that directory is embedded as-is. No node, no pnpm.
|
||||||
|
//! * unset — the default developer path: run `pnpm install --frozen-lockfile
|
||||||
|
//! && pnpm build` in `web/` and embed `web/dist`. pnpm and node are HARD
|
||||||
|
//! requirements here; a missing toolchain fails with an actionable message
|
||||||
|
//! rather than silently shipping the placeholder.
|
||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-env-changed=MAESTRO_WEB_DIST");
|
||||||
|
|
||||||
|
let dist_dir = match env::var_os("MAESTRO_WEB_DIST") {
|
||||||
|
Some(prebuilt) => PathBuf::from(prebuilt),
|
||||||
|
None => build_with_pnpm(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sanity-check that the directory really holds a bundle. A green build
|
||||||
|
// that embeds an empty/placeholder dir is worse than a loud failure here.
|
||||||
|
let index_html = dist_dir.join("index.html");
|
||||||
|
let produced = std::fs::metadata(&index_html)
|
||||||
|
.map(|m| m.is_file() && m.len() > 0)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !produced {
|
||||||
|
panic!(
|
||||||
|
"no SPA bundle at {} (missing or empty index.html) — \
|
||||||
|
check the vite `outDir`, or the MAESTRO_WEB_DIST override.",
|
||||||
|
index_html.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `include_dir!` expands `$VAR` in its path argument against the env rustc
|
||||||
|
// is invoked with, which is what `cargo:rustc-env` sets.
|
||||||
|
println!(
|
||||||
|
"cargo:rustc-env=MAESTRO_WEB_BUNDLE_DIR={}",
|
||||||
|
dist_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default path: build `web/` in-tree with pnpm and return `web/dist`.
|
||||||
|
fn build_with_pnpm() -> PathBuf {
|
||||||
let manifest_dir =
|
let manifest_dir =
|
||||||
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"));
|
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"));
|
||||||
let web_dir = manifest_dir.join("../../web");
|
let web_dir = manifest_dir.join("../../web");
|
||||||
@@ -29,11 +66,11 @@ fn main() {
|
|||||||
|
|
||||||
// Also watch the embedded OUTPUT directory. The input watches above decide
|
// Also watch the embedded OUTPUT directory. The input watches above decide
|
||||||
// whether THIS script re-runs and regenerates `web/dist`; but `include_dir!`
|
// whether THIS script re-runs and regenerates `web/dist`; but `include_dir!`
|
||||||
// in `static_bundle.rs` reads `web/dist`, and cargo has no other declared
|
// in `static_bundle.rs` reads that directory, and cargo has no other
|
||||||
// dependency on those output files. Without this watch, an incremental build
|
// declared dependency on those output files. Without this watch, an
|
||||||
// could regenerate `web/dist` yet keep the OLD bundle embedded. Tying the
|
// incremental build could regenerate `web/dist` yet keep the OLD bundle
|
||||||
// crate's recompilation to the regenerated output makes the fresh assets
|
// embedded. Tying the crate's recompilation to the regenerated output makes
|
||||||
// actually get re-embedded.
|
// the fresh assets actually get re-embedded.
|
||||||
let dist_dir = web_dir.join("dist");
|
let dist_dir = web_dir.join("dist");
|
||||||
println!("cargo:rerun-if-changed={}", dist_dir.display());
|
println!("cargo:rerun-if-changed={}", dist_dir.display());
|
||||||
|
|
||||||
@@ -51,20 +88,7 @@ fn main() {
|
|||||||
"pnpm build",
|
"pnpm build",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sanity-check that pnpm build actually produced the bundle `include_dir!`
|
dist_dir
|
||||||
// expects. A green build that embeds an empty/placeholder dir is worse than
|
|
||||||
// a loud failure here.
|
|
||||||
let index_html = dist_dir.join("index.html");
|
|
||||||
let produced = std::fs::metadata(&index_html)
|
|
||||||
.map(|m| m.is_file() && m.len() > 0)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !produced {
|
|
||||||
panic!(
|
|
||||||
"`pnpm build` did not produce the expected bundle at {} \
|
|
||||||
(missing or empty) — check the vite `outDir`.",
|
|
||||||
index_html.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_tool(tool: &str) {
|
fn require_tool(tool: &str) {
|
||||||
@@ -76,7 +100,8 @@ fn require_tool(tool: &str) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
panic!(
|
panic!(
|
||||||
"maestro-web requires `{tool}` to build its embedded SPA bundle. \
|
"maestro-web requires `{tool}` to build its embedded SPA bundle. \
|
||||||
Install pnpm + node (https://pnpm.io) or use a prebuilt binary."
|
Install pnpm + node (https://pnpm.io), or point MAESTRO_WEB_DIST \
|
||||||
|
at a prebuilt bundle."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use maestro_config::MaestroConfigFile;
|
use maestro_config::MaestroConfigFile;
|
||||||
|
|
||||||
pub use maestro_config::{default_config_path, resolve_config_path, WebSection, CONFIG_PATH_ENV};
|
pub use maestro_config::default_config_path;
|
||||||
|
|
||||||
|
use maestro_config::WebSection;
|
||||||
|
|
||||||
/// Default bind address when neither env nor file specify one.
|
/// Default bind address when neither env nor file specify one.
|
||||||
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
|
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
|
||||||
@@ -14,7 +16,6 @@ pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
|
|||||||
|
|
||||||
pub const ENV_WEB_BIND_ADDR: &str = "MAESTRO_WEB_BIND_ADDR";
|
pub const ENV_WEB_BIND_ADDR: &str = "MAESTRO_WEB_BIND_ADDR";
|
||||||
pub const ENV_WEB_DAEMON_URL: &str = "MAESTRO_WEB_DAEMON_URL";
|
pub const ENV_WEB_DAEMON_URL: &str = "MAESTRO_WEB_DAEMON_URL";
|
||||||
pub const ENV_WEB_DEV_PROXY_TARGET: &str = "MAESTRO_WEB_DEV_PROXY_TARGET";
|
|
||||||
|
|
||||||
fn default_bind_addr() -> SocketAddr {
|
fn default_bind_addr() -> SocketAddr {
|
||||||
DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr")
|
DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr")
|
||||||
@@ -33,17 +34,12 @@ pub struct WebConfig {
|
|||||||
/// Daemon HTTP base URL — e.g. `http://127.0.0.1:7070`. Always a base
|
/// Daemon HTTP base URL — e.g. `http://127.0.0.1:7070`. Always a base
|
||||||
/// without trailing slash and without a path.
|
/// without trailing slash and without a path.
|
||||||
pub daemon_url: String,
|
pub daemon_url: String,
|
||||||
/// Optional dev override: when set, `/` (and only `/`) is proxied here
|
|
||||||
/// instead of being served from the embedded bundle. Set to the Vite dev
|
|
||||||
/// server URL during frontend development.
|
|
||||||
pub dev_proxy_target: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct EnvOverrides {
|
pub struct EnvOverrides {
|
||||||
pub bind_addr: Option<String>,
|
pub bind_addr: Option<String>,
|
||||||
pub daemon_url: Option<String>,
|
pub daemon_url: Option<String>,
|
||||||
pub dev_proxy_target: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EnvOverrides {
|
impl EnvOverrides {
|
||||||
@@ -51,7 +47,6 @@ impl EnvOverrides {
|
|||||||
Self {
|
Self {
|
||||||
bind_addr: std::env::var(ENV_WEB_BIND_ADDR).ok(),
|
bind_addr: std::env::var(ENV_WEB_BIND_ADDR).ok(),
|
||||||
daemon_url: std::env::var(ENV_WEB_DAEMON_URL).ok(),
|
daemon_url: std::env::var(ENV_WEB_DAEMON_URL).ok(),
|
||||||
dev_proxy_target: std::env::var(ENV_WEB_DEV_PROXY_TARGET).ok(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,11 +54,7 @@ impl EnvOverrides {
|
|||||||
/// Resolve the runtime configuration. Missing-file is fine: every field has
|
/// 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.
|
/// 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> {
|
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<WebConfig, WebConfigError> {
|
||||||
let file = if path.exists() {
|
let file = MaestroConfigFile::load(path)?;
|
||||||
MaestroConfigFile::load(path)?
|
|
||||||
} else {
|
|
||||||
MaestroConfigFile::default()
|
|
||||||
};
|
|
||||||
resolve_with(file.web, env)
|
resolve_with(file.web, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,15 +71,9 @@ fn resolve_with(web: WebSection, env: EnvOverrides) -> Result<WebConfig, WebConf
|
|||||||
.trim_end_matches('/')
|
.trim_end_matches('/')
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let dev_proxy_target = env
|
|
||||||
.dev_proxy_target
|
|
||||||
.or(web.dev_proxy_target)
|
|
||||||
.map(|s| s.trim_end_matches('/').to_string());
|
|
||||||
|
|
||||||
Ok(WebConfig {
|
Ok(WebConfig {
|
||||||
bind_addr,
|
bind_addr,
|
||||||
daemon_url,
|
daemon_url,
|
||||||
dev_proxy_target,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +94,6 @@ mod tests {
|
|||||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
assert_eq!(cfg.bind_addr, default_bind_addr());
|
assert_eq!(cfg.bind_addr, default_bind_addr());
|
||||||
assert_eq!(cfg.daemon_url, "http://127.0.0.1:7070");
|
assert_eq!(cfg.daemon_url, "http://127.0.0.1:7070");
|
||||||
assert!(cfg.dev_proxy_target.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -122,17 +106,12 @@ mod tests {
|
|||||||
[web]
|
[web]
|
||||||
bind_addr = "127.0.0.1:9090"
|
bind_addr = "127.0.0.1:9090"
|
||||||
daemon_url = "http://127.0.0.1:9070"
|
daemon_url = "http://127.0.0.1:9070"
|
||||||
dev_proxy_target = "http://127.0.0.1:5173"
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||||
assert_eq!(cfg.bind_addr, "127.0.0.1:9090".parse().unwrap());
|
assert_eq!(cfg.bind_addr, "127.0.0.1:9090".parse().unwrap());
|
||||||
assert_eq!(cfg.daemon_url, "http://127.0.0.1:9070");
|
assert_eq!(cfg.daemon_url, "http://127.0.0.1:9070");
|
||||||
assert_eq!(
|
|
||||||
cfg.dev_proxy_target.as_deref(),
|
|
||||||
Some("http://127.0.0.1:5173")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -151,7 +130,6 @@ daemon_url = "http://127.0.0.1:9070"
|
|||||||
let env = EnvOverrides {
|
let env = EnvOverrides {
|
||||||
bind_addr: Some("0.0.0.0:8080".into()),
|
bind_addr: Some("0.0.0.0:8080".into()),
|
||||||
daemon_url: Some("http://daemon:7070".into()),
|
daemon_url: Some("http://daemon:7070".into()),
|
||||||
dev_proxy_target: None,
|
|
||||||
};
|
};
|
||||||
let cfg = load_config(&path, env).unwrap();
|
let cfg = load_config(&path, env).unwrap();
|
||||||
assert_eq!(cfg.bind_addr, "0.0.0.0:8080".parse().unwrap());
|
assert_eq!(cfg.bind_addr, "0.0.0.0:8080".parse().unwrap());
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
//! `maestro-web` — standalone HTTP server that hosts the Maestro browser UI
|
//! `maestro-web` — standalone HTTP server that hosts the Maestro browser UI
|
||||||
//! and reverse-proxies API calls to a co-located `maestrod`.
|
//! 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)]
|
#![deny(missing_debug_implementations)]
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user