Compare commits
285
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e9ed580ea | ||
|
|
2d5e2f1718 | ||
|
|
56d1afe9bd | ||
|
|
73e9ecfc85 | ||
|
|
095f810176 | ||
|
|
3e809db1bc | ||
|
|
e8bd8a4952 | ||
|
|
a85773cef0 | ||
|
|
9c6e06ec4f | ||
|
|
3029de667e | ||
|
|
a8d693c0a3 | ||
|
|
6c1bcf042c | ||
|
|
fb87a00906 | ||
|
|
d4dcfe7f00 | ||
|
|
6161cabe77 | ||
|
|
524a848fc6 | ||
|
|
187bcb01d6 | ||
|
|
003e3839bc | ||
|
|
d2079cac3a | ||
|
|
2083911baa | ||
|
|
b5450651d1 | ||
|
|
8aa007c73b | ||
|
|
f4d12fa02d | ||
|
|
31702e03e3 | ||
|
|
dde8dd8cfc | ||
|
|
2fc19a886e | ||
|
|
0b95d11aa6 | ||
|
|
a85084d0d9 | ||
|
|
5282ba3411 | ||
|
|
6f698df9f4 | ||
|
|
8e8540460f | ||
|
|
2d796a3556 | ||
|
|
1984bf9472 | ||
|
|
f67803acf5 | ||
|
|
0c135aa065 | ||
|
|
56c8e35d6f | ||
|
|
b96f18c7cb | ||
|
|
0166607064 | ||
|
|
bb2f439302 | ||
|
|
58b4257c92 | ||
|
|
86443d195e | ||
|
|
abaa498b9f | ||
|
|
62581d9f85 | ||
|
|
267f8a6712 | ||
|
|
a575b8bef1 | ||
|
|
e9e0136a89 | ||
|
|
4921f62796 | ||
|
|
898c3cc763 | ||
|
|
44b9745e8c | ||
|
|
cc8af94254 | ||
|
|
50a9d35f62 | ||
|
|
a9ef3ff97b | ||
|
|
0457a61a61 | ||
|
|
132b606830 | ||
|
|
35c75f9238 | ||
|
|
bbbc24a4aa | ||
|
|
e9368bb242 | ||
|
|
918609574e | ||
|
|
bcb365f278 | ||
|
|
586c243056 | ||
|
|
1647e64aed | ||
|
|
5aedaf838a | ||
|
|
d922cdaa32 | ||
|
|
b4e57d1a4f | ||
|
|
74967e45b4 | ||
|
|
7a2cc30bf9 | ||
|
|
8ed44e785d | ||
|
|
63da845614 | ||
|
|
d336bd802d | ||
|
|
67d17f48d0 | ||
|
|
ec45312400 | ||
|
|
525dc9a04a | ||
|
|
264332266c | ||
|
|
37338def71 | ||
|
|
51f91b3f5d | ||
|
|
5c3f9cab9f | ||
|
|
117f371c41 | ||
|
|
ad690ab2fa | ||
|
|
7dcb87cd6e | ||
|
|
26b0fa5e8a | ||
|
|
8b3c5d73b6 | ||
|
|
ca4f7cb1d5 | ||
|
|
cc1b3d45b8 | ||
|
|
f7a8dcae76 | ||
|
|
818e0cc2db | ||
|
|
23f01233b2 | ||
|
|
2a84e07f7d | ||
|
|
a970ba89c2 | ||
|
|
17796c1ffa | ||
|
|
01105f9424 | ||
|
|
b9829bc56f | ||
|
|
9f582263b8 | ||
|
|
b5cbf94fa8 | ||
|
|
8376122dfe | ||
|
|
4a814d5b17 | ||
|
|
1809f5d581 | ||
|
|
ec51b0221c | ||
|
|
ca585447ff | ||
|
|
71301861c0 | ||
|
|
33fb50be6e | ||
|
|
2ce1dc43fe | ||
|
|
b6cf87244f | ||
|
|
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 | ||
|
|
b67591695e | ||
|
|
851006cd2c | ||
|
|
d272210ecd | ||
|
|
159b7ecd6e | ||
|
|
5a7e3c1b09 | ||
|
|
3ef323f354 | ||
|
|
2999e888a8 | ||
|
|
e58f5da1fb | ||
|
|
6bb45ea8e4 | ||
|
|
1311b6c2da | ||
|
|
458c81094b | ||
|
|
6167559fea | ||
|
|
5602cf5fc8 | ||
|
|
f3c4784acc | ||
|
|
fd80ae152a | ||
|
|
c8bf240624 | ||
|
|
eef96548b8 | ||
|
|
658ec32444 | ||
|
|
b3ef2e543e | ||
|
|
f38b948aa6 | ||
|
|
cc8412ca59 | ||
|
|
1006651fb4 | ||
|
|
6b0c92265e | ||
|
|
ac11997f58 | ||
|
|
b5fabff8e4 | ||
|
|
d989a40772 | ||
|
|
7f3ac21402 | ||
|
|
317f8c52d0 | ||
|
|
ff33b43d44 | ||
|
|
099dfc17b0 | ||
|
|
09093bd2f6 | ||
|
|
4b775ec7cb | ||
|
|
a05966ed2f | ||
|
|
c02ff02849 | ||
|
|
eb2162889e | ||
|
|
b6833ede43 | ||
|
|
d1f32f6580 | ||
|
|
02475191df | ||
|
|
8e0ce7423e | ||
|
|
75d162dea3 | ||
|
|
71a45db0a4 | ||
|
|
881113d121 | ||
|
|
9d7f5456ef | ||
|
|
4c49abfdf2 | ||
|
|
4247b5706e | ||
|
|
ba73a713db | ||
|
|
473aa948a8 | ||
|
|
977800951d | ||
|
|
3aedd7a0a5 | ||
|
|
498cb471b7 | ||
|
|
1f20d70a2e | ||
|
|
8842147164 | ||
|
|
23594a5f00 | ||
|
|
815ad695df | ||
|
|
e909790b56 | ||
|
|
c2f32779a3 | ||
|
|
6d7cd0b4c8 | ||
|
|
906538c776 | ||
|
|
1cdb126977 | ||
|
|
1e4b65c73c | ||
|
|
daeacc750f | ||
|
|
f11c75a4fd | ||
|
|
4a008684f6 | ||
|
|
5ea8d0fa59 | ||
|
|
eb6aeb4241 | ||
|
|
f3f696790c | ||
|
|
ae588b76d5 | ||
|
|
ae3775946d | ||
|
|
939d67615a | ||
|
|
2f4dde85ac | ||
|
|
3167364505 | ||
|
|
b0774934e8 | ||
|
|
287afa26ea | ||
|
|
599f3ed178 | ||
|
|
3517e4e145 | ||
|
|
421e36c59f | ||
|
|
8b4cdfd650 | ||
|
|
4a20bc168f | ||
|
|
643da30f63 | ||
|
|
0df1d61f07 | ||
|
|
3b60f67332 | ||
|
|
2bfab13b58 | ||
|
|
39dddb17da | ||
|
|
f208aa441d | ||
|
|
f27adf5aba | ||
|
|
1cafa2c2af | ||
|
|
212ebbf120 | ||
|
|
278c81d2bc | ||
|
|
c599fcfdec | ||
|
|
5a17de4489 | ||
|
|
50688c39b7 | ||
|
|
953d170dae | ||
|
|
a429c69956 | ||
|
|
888a00d177 | ||
|
|
0f3770f711 | ||
|
|
0ed2a66176 | ||
|
|
eeccc1ad5b | ||
|
|
c355f9bd2e | ||
|
|
092a04857f | ||
|
|
31e1b73193 | ||
|
|
231032aa85 | ||
|
|
67870bb9e8 | ||
|
|
a4252438cd | ||
|
|
ed128ec200 | ||
|
|
92396a6bfe | ||
|
|
e276fcf43e | ||
|
|
c99f23c705 | ||
|
|
63217d7851 | ||
|
|
d0f8997a78 | ||
|
|
2b96330534 | ||
|
|
973b49e3df | ||
|
|
9ce6303293 | ||
|
|
ade0d49096 | ||
|
|
35482118b1 | ||
|
|
8e857e9fe7 | ||
|
|
f69c7a9eee | ||
|
|
7814ca99de | ||
|
|
08f5bbc045 | ||
|
|
d23ee6a8b2 | ||
|
|
1c5b6c615c |
+4
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"org": "naps62",
|
||||
"team": "Naps62",
|
||||
"project": "Maestro",
|
||||
"tracker": "gitea",
|
||||
"defaultBranch": "main",
|
||||
"commitScope": "maestro",
|
||||
"remoteHost": "gitea",
|
||||
"remoteBaseUrl": "https://git.naps.pt",
|
||||
"contextFiles": ["CLAUDE.md"],
|
||||
"contextFiles": [
|
||||
"CLAUDE.md"
|
||||
],
|
||||
"prReviewers": [],
|
||||
"labels": []
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
target/
|
||||
worktrees/
|
||||
.git/
|
||||
.github/
|
||||
.claude/
|
||||
docs/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
*.md
|
||||
.env*
|
||||
@@ -26,6 +26,13 @@ jobs:
|
||||
--component rustfmt --component clippy
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Node.js + pnpm
|
||||
run: |
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y --no-install-recommends nodejs
|
||||
corepack enable
|
||||
corepack prepare pnpm@9.15.4 --activate
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -45,3 +52,48 @@ jobs:
|
||||
|
||||
- name: test
|
||||
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
|
||||
|
||||
+13
@@ -2,3 +2,16 @@
|
||||
**/*.rs.bk
|
||||
Cargo.lock.bak
|
||||
.DS_Store
|
||||
|
||||
# Local git worktrees for in-flight branches (never tracked)
|
||||
worktrees/
|
||||
|
||||
# nix build output symlinks
|
||||
/result
|
||||
/result-*
|
||||
|
||||
# direnv (`use flake`)
|
||||
/.direnv/
|
||||
|
||||
# `nix run .#dev` scratch state (tmux socket)
|
||||
/.dev/
|
||||
|
||||
@@ -1,46 +1,82 @@
|
||||
# Maestro
|
||||
|
||||
A unified terminal-session orchestrator. One place — laptop, desktop, or phone — to see and attach to every interactive shell or agent (Claude Code, opencode, plain bash) running across every machine you care about. tmux for persistence, WebSocket for transport, daemon-per-host federated by a small always-on registry.
|
||||
A terminal-session orchestrator for one machine. One daemon per host owns every
|
||||
interactive shell or agent (Claude Code, opencode, plain bash) running on it;
|
||||
clients — CLI, TUI, browser — attach over a plain HTTP + WebSocket API. tmux for
|
||||
persistence, WebSocket for transport.
|
||||
|
||||
## Where the spec lives
|
||||
## Architecture
|
||||
|
||||
`docs/superpowers/specs/2026-05-24-maestro-design.md` is the source of truth for architecture, wire protocol, session model, auth, repo layout, and scope boundary. **Read it before writing any code in this repo.**
|
||||
```
|
||||
maestro daemon tmux wrapper + session API on 127.0.0.1:7070
|
||||
├── GET/POST /api/sessions list / spawn
|
||||
├── DELETE /api/sessions/:id kill
|
||||
├── GET /api/info hostname, version, home dir, spawn presets
|
||||
├── GET /api/dirs directory listing (spawn autocomplete)
|
||||
├── GET /api/recent-dirs spawn history
|
||||
├── GET /api/gitroot enclosing repo of a path
|
||||
├── WS /api/events Snapshot + session deltas
|
||||
└── WS /api/attach/:id raw PTY bytes
|
||||
|
||||
maestro list/spawn/attach/kill CLI client
|
||||
maestro tui ratatui client
|
||||
maestro-web serves the SPA, reverse-proxies /api/* to the daemon
|
||||
```
|
||||
|
||||
**No authentication layer, no registry, no peer-to-peer.** Whoever can reach the
|
||||
daemon's bind address can drive every session on the host — same trust model as
|
||||
tmux itself. The operator picks the perimeter: `127.0.0.1` for local-only, a
|
||||
WireGuard/LAN address to share with their own devices, never a public IP.
|
||||
|
||||
Every config field has a default, so `~/.config/maestro/config.toml` is
|
||||
optional.
|
||||
|
||||
## Where the work is
|
||||
|
||||
Linear project **Maestro** under team **Naps62**. Issues `NAP-211..218` cover the prototype; each has `blockedBy` set so the dependency chain is explicit. **`NAP-211` is the only unblocked starting point.**
|
||||
Linear project **Maestro** under team **Naps62**.
|
||||
|
||||
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from "available now" (status backlog/pending, not blocked).
|
||||
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from
|
||||
"available now" (status backlog/pending, not blocked).
|
||||
|
||||
## Repo layout (target)
|
||||
## Repo layout
|
||||
|
||||
Cargo workspace, populated incrementally starting with NAP-211:
|
||||
Cargo workspace. NAP-245 unified the per-binary crates behind a single
|
||||
`crates/maestro/` umbrella that ships the `maestro` binary with feature gates
|
||||
(`cli` / `daemon` / `tui`, default = all three).
|
||||
|
||||
```
|
||||
crates/
|
||||
├── maestro/ # umbrella binary `maestro` (cli + daemon + tui, feature-gated) (NAP-245)
|
||||
├── maestro-protocol/ # shared wire types (NAP-211)
|
||||
├── maestro-daemon/ # per-host tmux wrapper + WS endpoints (NAP-212, NAP-214)
|
||||
├── maestro-registry/ # always-on phone book + HTTP API (NAP-213, NAP-215)
|
||||
├── maestro-cli/ # `maestro list/spawn/attach/kill` (NAP-216)
|
||||
├── maestro-config/ # shared on-disk config schema (NAP-233)
|
||||
├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216)
|
||||
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API (NAP-212, NAP-214)
|
||||
├── maestro-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249)
|
||||
├── maestro-tui/ # `maestro tui` — ratatui session manager (NAP-275 epic)
|
||||
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
|
||||
└── maestro-e2e/ # cross-process integration tests (NAP-217)
|
||||
web/ # browser client (NAP-218, plan 2)
|
||||
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.
|
||||
|
||||
`docs/WORKTREES.md` is the contract for session worktrees — placement,
|
||||
collisions, what blocks a kill, what `--force` retains, and what reclaim
|
||||
touches. Read it before changing anything that creates or removes one.
|
||||
|
||||
`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
|
||||
|
||||
- **TDD by default.** Failing test first, then minimal impl. Commit test + impl together.
|
||||
- **Frequent commits.** One logical unit per commit; don't pile work up.
|
||||
- **Conventional commits with `maestro` scope.** Example: `feat(maestro): add Hello message round-trip test`.
|
||||
- **`cargo fmt` + `cargo clippy --all-targets -- -D warnings`** before every commit. (Add to CI as part of NAP-211.)
|
||||
- **`cargo fmt` + `cargo clippy --all-targets -- -D warnings`** before every commit.
|
||||
- **Integration tests use real `tmux` and `git`.** Don't mock them. Isolate via temp `$HOME` and `TMUX_TMPDIR`.
|
||||
|
||||
## Network assumption
|
||||
|
||||
Maestro is network-agnostic. The operator chooses bind addresses; the perimeter is whatever VPN (Wireguard, etc.) or private LAN they've set up. Maestro does not terminate TLS itself or police the network. See spec §Auth.
|
||||
|
||||
## Status
|
||||
|
||||
Empty repo. Spec approved and committed. **NAP-211 is the natural start.**
|
||||
|
||||
Generated
+404
-194
@@ -11,6 +11,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -76,6 +82,12 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -196,6 +208,21 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
@@ -277,32 +304,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
name = "compact_str"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
|
||||
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",
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -320,6 +332,32 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"futures-core",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -330,21 +368,46 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -388,13 +451,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
@@ -555,6 +615,8 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
@@ -787,6 +849,12 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -839,6 +907,28 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -851,6 +941,15 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -869,19 +968,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"js-sys",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -909,6 +995,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -922,10 +1014,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
@@ -933,12 +1028,33 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "maestro"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"maestro-cli",
|
||||
"maestro-daemon",
|
||||
"maestro-tui",
|
||||
"tokio",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-cli"
|
||||
version = "0.1.0"
|
||||
@@ -950,9 +1066,8 @@ dependencies = [
|
||||
"libc",
|
||||
"maestro-config",
|
||||
"maestro-protocol",
|
||||
"maestro-testing",
|
||||
"nix",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
@@ -969,6 +1084,7 @@ name = "maestro-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"maestro-protocol",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
@@ -984,11 +1100,10 @@ dependencies = [
|
||||
"clap",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"jsonwebtoken",
|
||||
"libc",
|
||||
"maestro-config",
|
||||
"maestro-names",
|
||||
"maestro-protocol",
|
||||
"maestro-registry",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -996,7 +1111,6 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.21.0",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
@@ -1010,7 +1124,8 @@ dependencies = [
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"maestro-protocol",
|
||||
"reqwest",
|
||||
"maestro-testing",
|
||||
"maestro-tui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -1032,40 +1147,60 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-names"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-protocol"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-registry"
|
||||
name = "maestro-testing"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"jsonwebtoken",
|
||||
"maestro-config",
|
||||
"maestro-protocol",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-tui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"futures-util",
|
||||
"maestro-config",
|
||||
"maestro-protocol",
|
||||
"maestro-testing",
|
||||
"ratatui",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.21.0",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"vt100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1079,6 +1214,8 @@ dependencies = [
|
||||
"futures-util",
|
||||
"include_dir",
|
||||
"maestro-config",
|
||||
"maestro-protocol",
|
||||
"maestro-testing",
|
||||
"mime_guess",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
@@ -1088,6 +1225,7 @@ dependencies = [
|
||||
"tokio-tungstenite 0.21.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1134,6 +1272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -1159,12 +1298,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1192,6 +1325,35 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1213,12 +1375,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1247,22 +1403,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -1398,6 +1538,36 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm",
|
||||
"instability",
|
||||
"itertools",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
@@ -1434,8 +1604,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
@@ -1483,46 +1651,25 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -1532,7 +1679,7 @@ dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1609,13 +1756,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
@@ -1709,17 +1853,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -1735,6 +1868,27 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -1773,12 +1927,40 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -1825,7 +2007,7 @@ dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1878,37 +2060,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -2235,6 +2386,29 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -2308,13 +2482,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
name = "vt100"
|
||||
version = "0.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
"itoa",
|
||||
"log",
|
||||
"unicode-width",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"utf8parse",
|
||||
"vte_generate_state_changes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte_generate_state_changes"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2491,14 +2688,27 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
||||
+7
-3
@@ -13,16 +13,18 @@ publish = false
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
dirs = "5"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
jsonwebtoken = { version = "9", default-features = false }
|
||||
libc = "0.2"
|
||||
maestro-config = { path = "crates/maestro-config" }
|
||||
maestro-names = { path = "crates/maestro-names" }
|
||||
maestro-protocol = { path = "crates/maestro-protocol" }
|
||||
maestro-testing = { path = "crates/maestro-testing" }
|
||||
maestro-tui = { path = "crates/maestro-tui" }
|
||||
nix = { version = "0.29", default-features = false, features = ["term", "signal"] }
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "stream"] }
|
||||
rust-embed = "8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
include_dir = "0.7"
|
||||
mime_guess = "2"
|
||||
bytes = "1"
|
||||
@@ -36,6 +38,8 @@ toml = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
ratatui = "0.28"
|
||||
vt100 = "0.15"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Design
|
||||
|
||||
Visual system for the Maestro web UI. Source of truth is
|
||||
`web/src/styles.css` (`:root` token block); this document mirrors it. Register:
|
||||
**product** — design serves the task. See [PRODUCT.md](PRODUCT.md) for strategy.
|
||||
|
||||
## Theme
|
||||
|
||||
Dark is the default (terminal context); light is a full peer. Everything is
|
||||
driven by CSS custom-property tokens, so a theme is a token swap, never a
|
||||
restyle. Resolution order:
|
||||
|
||||
1. `:root[data-theme="light"|"dark"]` — explicit user pin (persisted in
|
||||
`localStorage` as `maestro-theme`), set by the header toggle.
|
||||
2. `@media (prefers-color-scheme: light)` — followed when the user hasn't
|
||||
pinned a theme.
|
||||
3. Otherwise the `:root` defaults (dark).
|
||||
|
||||
The xterm terminal stays dark in both themes by convention; `--term-bg` is dark
|
||||
in light mode too.
|
||||
|
||||
## Color (OKLCH)
|
||||
|
||||
Strategy: **Restrained** (product floor) with one committed signal hue —
|
||||
**green = "alive"**, carrying running status, primary action, focus, and
|
||||
selection. Neutrals are a cool slate (hue ~256). Green is the signal, never a
|
||||
costume: text stays neutral high-contrast, surfaces stay slate, so the result
|
||||
reads terminal-native without CRT novelty.
|
||||
|
||||
| Role | Dark | Light |
|
||||
|---|---|---|
|
||||
| `--bg` | `oklch(0.178 0.012 256)` | `oklch(0.985 0.003 256)` |
|
||||
| `--surface` | `oklch(0.214 0.014 256)` | `oklch(1 0 0)` |
|
||||
| `--surface-2` | `oklch(0.252 0.016 256)` | `oklch(0.965 0.004 256)` |
|
||||
| `--surface-3` | `oklch(0.292 0.018 256)` | `oklch(0.93 0.006 256)` |
|
||||
| `--border` | `oklch(0.318 0.018 256)` | `oklch(0.892 0.008 256)` |
|
||||
| `--border-strong` | `oklch(0.4 0.022 256)` | `oklch(0.82 0.012 256)` |
|
||||
| `--ink` (body text) | `oklch(0.945 0.006 256)` | `oklch(0.26 0.014 256)` |
|
||||
| `--muted` (secondary) | `oklch(0.722 0.014 256)` | `oklch(0.47 0.016 256)` |
|
||||
| `--faint` (tertiary/decorative) | `oklch(0.58 0.014 256)` | `oklch(0.62 0.016 256)` |
|
||||
| `--accent` / `--live` | `oklch(0.83–0.84 0.16–0.17 152)` | `oklch(0.6 0.15–0.16 151)` |
|
||||
| `--accent-ink` (text on accent) | `oklch(0.2 0.03 256)` | `oklch(0.99 0.01 152)` |
|
||||
| `--danger` | `oklch(0.72 0.18 25)` | `oklch(0.55 0.21 27)` |
|
||||
| `--warn` | `oklch(0.82 0.14 85)` | `oklch(0.66 0.15 75)` |
|
||||
|
||||
Body and secondary text meet WCAG AA (≥4.5:1) against their surface in both
|
||||
themes; `--faint` is reserved for decorative/large use only. Status never relies
|
||||
on color alone — a running dot pulses and is filled; a dead session is a hollow
|
||||
ring **and** carries an "exited" badge.
|
||||
|
||||
Tints/glow derive from the accent at low alpha: `--accent-tint` (0.12),
|
||||
`--accent-ring` (focus, ~0.55), `--live-glow` (status halo).
|
||||
|
||||
## Typography
|
||||
|
||||
**Monospace is the UI typeface** (brand identity), system-stack only (offline-
|
||||
safe, zero web-font dependency, embedded clean into the web binary):
|
||||
|
||||
- `--font-mono`: `ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", "Roboto Mono", Menlo, Consolas, monospace` — chrome, labels, data, ids, the wordmark.
|
||||
- `--font-sans`: `system-ui` stack — reserved for the few prose strings (empty-state sentences).
|
||||
|
||||
Fixed rem scale (product register; not fluid/clamped): `--fs-11` 0.6875rem
|
||||
through `--fs-20` 1.25rem, in ~1.12–1.2 steps. Base body `--fs-15` (0.9375rem),
|
||||
line-height 1.45. No display sizes — the largest type is the 1.25rem wordmark.
|
||||
|
||||
## Spacing, radius, sizing
|
||||
|
||||
- Spacing scale: `--sp-1`..`--sp-8` = 4 / 8 / 12 / 16 / 20 / 24 / 32 px.
|
||||
- Radius: `--r-1` 6 · `--r-2` 9 · `--r-3` 14 · `--r-pill` 999.
|
||||
- Touch/row sizing: `--row-h` 46, `--control-h` 38, `--tap` 40 (mobile-first
|
||||
touch targets).
|
||||
|
||||
## Motion
|
||||
|
||||
State and feedback only; 120–240 ms, ease-out (`cubic-bezier(0.22,1,0.36,1)`),
|
||||
no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
|
||||
|
||||
- Living signals: brand dot + running-status ripple pulse (the "alive" cue);
|
||||
`connecting` state pill pulses.
|
||||
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
|
||||
rotation, theme-toggle sun/moon swap.
|
||||
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
|
||||
transitions near-instant).
|
||||
|
||||
## Layout & structure
|
||||
|
||||
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
|
||||
breakpoint stacks the header (full-width search, wrapped actions). A faint
|
||||
slate grid texture sits on `--bg` as terminal atmosphere (very low contrast,
|
||||
not decoration).
|
||||
|
||||
## Components
|
||||
|
||||
Three surfaces, all DOM built in TS, styled by class:
|
||||
|
||||
- **Catalog** — sticky blurred header (pulsing wordmark, search, theme toggle,
|
||||
Spawn); status bar (`N sessions · M live` tally); session tree (collapsible
|
||||
directory folders with counts, leaf rows with status dot, name, command meta,
|
||||
exited badge, reveal-on-hover kill). Empty states are distinct and teach:
|
||||
no-sessions, no-search-match.
|
||||
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
|
||||
a framed dark xterm with a green cursor.
|
||||
- **Draft pane** — the spawn form mounted inline in an empty split pane,
|
||||
focusable like any session pane; mono inputs with green focus ring; grouped
|
||||
worktree fieldset. Spawn opens one (reusing an empty focused pane, else
|
||||
splitting), and "Spawn similar" seeds one from a session's dir + command.
|
||||
|
||||
Interactive elements ship default / hover / focus-visible / active / disabled
|
||||
states; the accent green is used only for action, selection, focus, and live
|
||||
status — never decoration.
|
||||
|
||||
## Dev review aids (DEV-only, stripped from production)
|
||||
|
||||
`web/src/mock.ts` + query params seed a representative catalog without a running
|
||||
daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane),
|
||||
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Image for the `maestro-registry` binary.
|
||||
#
|
||||
# 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.
|
||||
|
||||
# 1. Build the web client bundle. Output lands in /web/dist and is consumed
|
||||
# by the Rust builder below via `rust-embed`.
|
||||
FROM node:20-bookworm AS web-builder
|
||||
WORKDIR /web
|
||||
RUN corepack enable
|
||||
COPY web/package.json web/pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
COPY web/ ./
|
||||
RUN pnpm build
|
||||
|
||||
# 2. Build the registry binary. `rust-embed` picks up the bundle that the
|
||||
# previous stage produced.
|
||||
FROM rust:1-bookworm AS builder
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
COPY --from=web-builder /web/dist /build/web/dist
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/build/target \
|
||||
cargo build --release -p maestro-registry \
|
||||
&& cp /build/target/release/maestro-registry /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
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/maestro-registry"]
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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.
|
||||
web:
|
||||
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
|
||||
# frontend with HMR, and `/api/*` is proxied directly to the daemon at
|
||||
# 127.0.0.1:7070 (or wherever $MAESTRO_REGISTRY points; see vite.config.ts).
|
||||
# 127.0.0.1:7070 (see vite.config.ts).
|
||||
web-dev:
|
||||
pnpm -C web install
|
||||
pnpm -C web dev
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
A single technical operator (a developer) managing every interactive shell and
|
||||
agent — Claude Code, opencode, plain bash — running on a machine. The
|
||||
job-to-be-done: from any device (laptop, desktop, **phone**), get an accurate
|
||||
at-a-glance picture of all live sessions on that host, then attach to one and
|
||||
drive its terminal. Usage is often glance-and-go: checking on a
|
||||
long-running agent, killing a stray session, or hopping into a shell mid-task.
|
||||
Mobile-first is a hard requirement — touch targets, viewport-fit safe areas,
|
||||
and bottom-sheet modals already reflect this.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Maestro is a terminal-session orchestrator: one place to see and attach to
|
||||
every interactive session running on a machine. tmux for persistence,
|
||||
WebSocket for transport, one daemon per host. The web UI is the browser face of
|
||||
that system — it renders the session catalog and provides the attach terminal
|
||||
(xterm.js). Success = the operator trusts the picture is accurate and
|
||||
current, and can get from "open the page" to "typing in the right session" in
|
||||
seconds, on any device.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Three words: **precise, calm, alive.** This is infrastructure tooling — it must
|
||||
read as trustworthy and exact, never toy-like — but it watches living
|
||||
processes, so status must feel present and current, not static. Voice is terse
|
||||
and technical, matching the CLI and the repo's terse house style. It is a
|
||||
craftsman's tool: opinionated, monospace-literate, with quiet personality
|
||||
rather than decoration. Emotional goals: in control / confident, fast /
|
||||
efficient, calm / uncluttered, with earned moments of delight.
|
||||
|
||||
## Anti-references
|
||||
|
||||
- Generic Tailwind-admin / SaaS-cream dashboards.
|
||||
- Heavy gradients, glassmorphism, glass cards as decoration.
|
||||
- Playful, rounded, "friendly app" aesthetics that undercut the
|
||||
infrastructure-tool trust.
|
||||
- Whitespace-maximalist layouts that trade away the at-a-glance scan for
|
||||
"elegance."
|
||||
- Anything where you could guess the design from the category alone
|
||||
("terminal tool → CRT-green-on-black novelty"): the orchestrator identity is
|
||||
earned through restraint, not costume.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **The scan is the product.** Optimize for instant comprehension of "what's
|
||||
running, where, and is it healthy" across many sessions. Status legibility
|
||||
beats everything else.
|
||||
2. **Calm under load.** Stay uncluttered whether there are 3 sessions or 300;
|
||||
hierarchy and grouping carry the weight, not color-shouting every row.
|
||||
3. **Fast and keyboard-literate.** Low-friction, efficient, gets out of the
|
||||
way; honor keyboard navigation, don't make the operator hunt.
|
||||
4. **Monospace as identity.** Treat the mono/sans pairing as deliberate brand
|
||||
typography, not just terminal output. Ids, paths, counts, and state read as
|
||||
precise technical data.
|
||||
5. **Earned delight.** Personality lives in motion and micro-detail (live-status
|
||||
pulse, attach transitions, cursor cues), never in noise — always craft.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Standard good practice, applied rigorously: WCAG AA contrast for all text and
|
||||
status, visible keyboard focus on every interactive element, and a full
|
||||
`prefers-reduced-motion` alternative for every animation. Status must never rely
|
||||
on color alone (pair the dot with a label/shape). Dark and light themes are
|
||||
equal citizens, driven entirely by CSS custom-property tokens.
|
||||
@@ -10,10 +10,6 @@ description = "`maestro` CLI: list, spawn, attach, kill sessions across daemons"
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "maestro"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
@@ -21,10 +17,8 @@ dirs.workspace = true
|
||||
futures-util.workspace = true
|
||||
libc.workspace = true
|
||||
maestro-config.workspace = true
|
||||
maestro-protocol.workspace = true
|
||||
maestro-protocol = { workspace = true, features = ["client"] }
|
||||
nix.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt", "sync", "time", "io-util", "io-std", "signal"] }
|
||||
@@ -35,4 +29,7 @@ tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
maestro-testing.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 futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{ClientControl, ConnectInfo};
|
||||
use maestro_protocol::ClientControl;
|
||||
use nix::sys::termios::{cfmakeraw, tcgetattr, tcsetattr, SetArg, Termios};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(clippy::result_large_err)]
|
||||
@@ -24,14 +23,33 @@ pub enum AttachError {
|
||||
Io(#[from] io::Error),
|
||||
#[error("termios: {0}")]
|
||||
Termios(#[from] nix::Error),
|
||||
#[error("invalid daemon_url: {0}")]
|
||||
InvalidDaemonUrl(String),
|
||||
#[error(transparent)]
|
||||
Url(#[from] maestro_protocol::routes::UrlError),
|
||||
#[error("invalid detach key: {0}")]
|
||||
InvalidDetachKey(String),
|
||||
#[error("stdin must be a TTY for attach")]
|
||||
NotATty,
|
||||
}
|
||||
|
||||
impl AttachError {
|
||||
/// True when the failure was an inability to reach the WS endpoint at the
|
||||
/// transport level (connection refused), as opposed to an HTTP-level
|
||||
/// rejection from a daemon that did answer.
|
||||
pub fn is_unreachable(&self) -> bool {
|
||||
use std::io::ErrorKind;
|
||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||
match self {
|
||||
AttachError::WebSocket(WsError::Io(e)) => matches!(
|
||||
e.kind(),
|
||||
ErrorKind::ConnectionRefused
|
||||
| ErrorKind::ConnectionReset
|
||||
| ErrorKind::ConnectionAborted
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a detach key spec. Accepts `^X` for Ctrl-X (case-insensitive) or a
|
||||
/// single literal character. Returns the resulting byte.
|
||||
#[allow(clippy::result_large_err)]
|
||||
@@ -44,37 +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(&info.jwt)
|
||||
))
|
||||
}
|
||||
|
||||
/// Minimal percent-encoder for the token in a query string. JWTs only contain
|
||||
/// `A-Z a-z 0-9 - _ .` (URL-safe base64 + dots) so this is paranoia, not need.
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
out.push('%');
|
||||
out.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// RAII guard that puts the terminal into raw mode and restores on drop.
|
||||
#[derive(Debug)]
|
||||
struct RawTty {
|
||||
@@ -119,19 +106,12 @@ fn current_winsize() -> Option<(u16, u16)> {
|
||||
Some((ws.ws_col, ws.ws_row))
|
||||
}
|
||||
|
||||
/// Drive the attach loop until the client detaches or the remote closes.
|
||||
///
|
||||
/// `connect_info` is whatever `GET /api/sessions/:id/connect-info` returned;
|
||||
/// `detach_byte` is the single byte that exits cleanly without killing the
|
||||
/// session.
|
||||
/// Drive the attach loop against an already-built WS `url` ([`attach_url`])
|
||||
/// until the client detaches or the remote closes. `detach_byte` is the single
|
||||
/// byte that exits cleanly without killing the session.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn run_attach(
|
||||
connect_info: ConnectInfo,
|
||||
session_id: Uuid,
|
||||
detach_byte: u8,
|
||||
) -> Result<(), AttachError> {
|
||||
let url = attach_url(&connect_info, session_id)?;
|
||||
let (ws, _resp) = tokio_tungstenite::connect_async(&url).await?;
|
||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
|
||||
let raw = RawTty::enable()?;
|
||||
@@ -233,8 +213,6 @@ pub async fn run_attach(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use maestro_protocol::ConnectInfo;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn parse_detach_key_handles_caret_form() {
|
||||
@@ -255,40 +233,4 @@ mod tests {
|
||||
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 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}");
|
||||
}
|
||||
}
|
||||
|
||||
+152
-38
@@ -9,14 +9,15 @@ use uuid::Uuid;
|
||||
#[command(
|
||||
name = "maestro",
|
||||
version,
|
||||
about = "Control terminal sessions across one or more maestrod hosts",
|
||||
about = "Control terminal sessions on the local maestro daemon",
|
||||
arg_required_else_help = true
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Override the registry URL for this invocation (e.g. `https://maestro.example.com`).
|
||||
/// If omitted, `[shared].registry_url` from the unified config file is used.
|
||||
#[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")]
|
||||
pub registry_url: Option<String>,
|
||||
/// Override the daemon HTTP base for this invocation (e.g.
|
||||
/// `http://127.0.0.1:7070`). If omitted, `[cli].daemon_url` from the
|
||||
/// unified config file is used.
|
||||
#[arg(long, global = true, env = "MAESTRO_DAEMON_URL")]
|
||||
pub daemon_url: Option<String>,
|
||||
|
||||
/// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`.
|
||||
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
|
||||
@@ -32,42 +33,61 @@ pub struct Cli {
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// List all sessions, grouped by daemon.
|
||||
/// List all sessions.
|
||||
List(ListArgs),
|
||||
/// Spawn a new session on a given daemon.
|
||||
/// Spawn a new session.
|
||||
Spawn(SpawnArgs),
|
||||
/// Attach to a session over WebSocket. Bytes flow through the local TTY.
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// Set the default registry URL persisted in the CLI config.
|
||||
SetRegistry(SetRegistryArgs),
|
||||
/// List retained worktrees, or reclaim one by ID (#86).
|
||||
Worktrees(WorktreesArgs),
|
||||
}
|
||||
|
||||
/// `maestro worktrees <list|reclaim>`
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct WorktreesArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: WorktreesCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum WorktreesCommand {
|
||||
/// List retained worktrees and what is in the way of reclaiming each.
|
||||
List(ListWorktreesArgs),
|
||||
/// Reclaim one retained worktree: removes the directory, never the branch.
|
||||
Reclaim(ReclaimArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ListArgs {
|
||||
/// Show only sessions on this daemon.
|
||||
#[arg(long)]
|
||||
pub daemon: Option<String>,
|
||||
pub struct ListWorktreesArgs {
|
||||
/// Output as JSON instead of a table.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct SpawnArgs {
|
||||
/// Daemon ID to spawn on.
|
||||
pub struct ReclaimArgs {
|
||||
/// Retained worktree ID to reclaim. Required — there is no reclaim-all.
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ListArgs {
|
||||
/// Output as JSON instead of a table.
|
||||
#[arg(long)]
|
||||
pub daemon: String,
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, clap::Args)]
|
||||
pub struct SpawnArgs {
|
||||
/// Working directory for the session (must exist on the daemon's host).
|
||||
#[arg(long)]
|
||||
pub cwd: PathBuf,
|
||||
/// Command to launch (e.g. `bash`, `claude`).
|
||||
#[arg(long)]
|
||||
pub command: String,
|
||||
/// Human-readable label, persisted in session metadata (`label`).
|
||||
#[arg(long)]
|
||||
pub label: Option<String>,
|
||||
/// Set an env var on the session (`KEY=VALUE`). Repeatable.
|
||||
#[arg(long = "env", short = 'e', value_parser = parse_kv, value_name = "KEY=VALUE")]
|
||||
pub env: Vec<(String, String)>,
|
||||
@@ -77,9 +97,11 @@ pub struct SpawnArgs {
|
||||
/// Branch name for the worktree (required with --worktree-from).
|
||||
#[arg(long)]
|
||||
pub branch: Option<String>,
|
||||
/// Remove the worktree when the session is killed.
|
||||
/// Check out an existing branch instead of creating one. Without it the
|
||||
/// daemon runs `worktree add -b`, which fails if the branch is already
|
||||
/// there.
|
||||
#[arg(long)]
|
||||
pub cleanup_on_kill: bool,
|
||||
pub existing_branch: bool,
|
||||
/// After spawning, attach immediately.
|
||||
#[arg(long, short = 'a')]
|
||||
pub attach: bool,
|
||||
@@ -98,12 +120,11 @@ pub struct AttachArgs {
|
||||
pub struct KillArgs {
|
||||
/// Session ID to kill.
|
||||
pub session_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct SetRegistryArgs {
|
||||
/// Registry base URL to persist.
|
||||
pub url: String,
|
||||
/// End the session even when its worktree holds unsaved work (#81). The
|
||||
/// worktree is retained — never removed — and enters the retained store;
|
||||
/// the output says where the work went.
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
fn parse_kv(s: &str) -> Result<(String, String), String> {
|
||||
@@ -120,10 +141,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_list() {
|
||||
let cli = Cli::try_parse_from(["maestro", "list", "--daemon", "laptop", "--json"]).unwrap();
|
||||
let cli = Cli::try_parse_from(["maestro", "list", "--json"]).unwrap();
|
||||
match cli.command {
|
||||
Command::List(args) => {
|
||||
assert_eq!(args.daemon.as_deref(), Some("laptop"));
|
||||
assert!(args.json);
|
||||
}
|
||||
_ => panic!("wrong subcommand"),
|
||||
@@ -135,8 +155,6 @@ mod tests {
|
||||
let cli = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--daemon",
|
||||
"vm1",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
@@ -145,7 +163,6 @@ mod tests {
|
||||
"/repos/foo",
|
||||
"--branch",
|
||||
"feat/x",
|
||||
"--cleanup-on-kill",
|
||||
"-e",
|
||||
"FOO=bar",
|
||||
"-e",
|
||||
@@ -154,10 +171,8 @@ mod tests {
|
||||
.unwrap();
|
||||
match cli.command {
|
||||
Command::Spawn(a) => {
|
||||
assert_eq!(a.daemon, "vm1");
|
||||
assert_eq!(a.command, "bash");
|
||||
assert_eq!(a.branch.as_deref(), Some("feat/x"));
|
||||
assert!(a.cleanup_on_kill);
|
||||
assert_eq!(
|
||||
a.env,
|
||||
vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),]
|
||||
@@ -167,13 +182,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Review fix: without a flag the CLI always sent `create_branch: true`,
|
||||
/// so the daemon's existing-branch path was unreachable and spawning onto
|
||||
/// a branch that already existed just failed.
|
||||
#[test]
|
||||
fn parses_spawn_onto_an_existing_branch() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
"bash",
|
||||
"--worktree-from",
|
||||
"/repo",
|
||||
"--branch",
|
||||
"feat/existing",
|
||||
"--existing-branch",
|
||||
])
|
||||
.unwrap();
|
||||
let Command::Spawn(a) = cli.command else {
|
||||
panic!("expected spawn")
|
||||
};
|
||||
assert!(a.existing_branch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_worktree_requires_branch() {
|
||||
let err = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--daemon",
|
||||
"vm1",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
@@ -195,13 +233,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_kill_with_force() {
|
||||
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
let cli = Cli::try_parse_from(["maestro", "kill", "--force", id]).unwrap();
|
||||
match cli.command {
|
||||
Command::Kill(a) => {
|
||||
assert!(a.force);
|
||||
assert_eq!(a.session_id.to_string(), id);
|
||||
}
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_force_defaults_false() {
|
||||
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
let cli = Cli::try_parse_from(["maestro", "kill", id]).unwrap();
|
||||
match cli.command {
|
||||
Command::Kill(a) => {
|
||||
assert!(!a.force);
|
||||
assert_eq!(a.session_id.to_string(), id);
|
||||
}
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_env_without_equals() {
|
||||
let err = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--daemon",
|
||||
"x",
|
||||
"--cwd",
|
||||
"/",
|
||||
"--command",
|
||||
@@ -212,4 +274,56 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("KEY=VALUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_worktrees_list() {
|
||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
|
||||
match cli.command {
|
||||
Command::Worktrees(w) => match w.command {
|
||||
WorktreesCommand::List(a) => assert!(a.json),
|
||||
_ => panic!("wrong worktrees subcommand"),
|
||||
},
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_worktrees_reclaim() {
|
||||
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap();
|
||||
match cli.command {
|
||||
Command::Worktrees(w) => match w.command {
|
||||
WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id),
|
||||
_ => panic!("wrong worktrees subcommand"),
|
||||
},
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Contract rule: reclaim refuses to run without an explicit target.
|
||||
/// There is no reclaim-everything convenience.
|
||||
#[test]
|
||||
fn reclaim_requires_an_explicit_target() {
|
||||
let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::InvalidSubcommand
|
||||
| clap::error::ErrorKind::MissingRequiredArgument
|
||||
),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktrees_without_subcommand_shows_help() {
|
||||
let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
||||
),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,172 +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::path::{Path, PathBuf};
|
||||
|
||||
use maestro_config::MaestroConfigFile;
|
||||
|
||||
pub use maestro_config::CONFIG_PATH_ENV;
|
||||
|
||||
/// `~/.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>,
|
||||
}
|
||||
|
||||
impl CliConfig {
|
||||
/// Load `[shared].registry_url` + `[cli].cookie` from the unified file.
|
||||
/// 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,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Public dispatch for the `maestro` CLI subcommands.
|
||||
//!
|
||||
//! Every command talks to the local daemon (`[cli].daemon_url`, default
|
||||
//! `http://127.0.0.1:7070`): `list` reads its session catalog, `spawn` / `kill`
|
||||
//! hit its session endpoints, and `attach` opens `WS /api/attach/:id`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use maestro_protocol::DaemonClient;
|
||||
|
||||
use crate::attach::{parse_detach_key, run_attach_url};
|
||||
use crate::cli::{Command, WorktreesCommand};
|
||||
use crate::sessions::{run_kill, run_list, run_spawn};
|
||||
use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim};
|
||||
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub enum CliError {
|
||||
#[error("config: {0}")]
|
||||
Config(#[from] maestro_config::ConfigError),
|
||||
#[error("{0}")]
|
||||
Client(#[from] maestro_protocol::ClientError),
|
||||
#[error("{0}")]
|
||||
Attach(#[from] crate::attach::AttachError),
|
||||
#[error("{0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Dispatch a parsed top-level subcommand. The umbrella binary passes the
|
||||
/// global flags (`--config`, `--daemon-url`) in alongside the variant.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn dispatch(
|
||||
command: Command,
|
||||
config: Option<PathBuf>,
|
||||
daemon_url_override: Option<String>,
|
||||
) -> Result<(), CliError> {
|
||||
let config_path = match config {
|
||||
Some(p) => p,
|
||||
None => default_config_path()?,
|
||||
};
|
||||
|
||||
let daemon = client(&config_path, daemon_url_override)?;
|
||||
|
||||
match command {
|
||||
Command::List(args) => {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
run_list(&daemon, &args, &mut stdout).await?;
|
||||
}
|
||||
Command::Spawn(args) => {
|
||||
let attach_after = args.attach;
|
||||
let session = run_spawn(&daemon, args).await?;
|
||||
println!("spawned {}", session.id);
|
||||
if attach_after {
|
||||
// `spawn --attach` has no detach-key flag; use the attach
|
||||
// default (Ctrl-q).
|
||||
run_attach_url(&daemon.attach_url(session.id)?, CTRL_Q).await?;
|
||||
}
|
||||
}
|
||||
Command::Attach(args) => {
|
||||
let detach = parse_detach_key(&args.detach_key)?;
|
||||
run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?;
|
||||
}
|
||||
Command::Kill(args) => {
|
||||
// #83: warn BEFORE the attempt, so the refusal is not a surprise.
|
||||
// Best-effort: an unreachable list must not block the kill.
|
||||
if !args.force {
|
||||
warn_if_blocked(&daemon, args.session_id).await;
|
||||
}
|
||||
let outcome = run_kill(&daemon, &args).await?;
|
||||
println!("killed {}", args.session_id);
|
||||
if let Some(w) = outcome.retained {
|
||||
println!(
|
||||
"worktree retained at {} (branch {}, force-killed)",
|
||||
w.path.display(),
|
||||
w.branch
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::Worktrees(args) => match args.command {
|
||||
WorktreesCommand::List(list_args) => {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
run_worktree_list(&daemon, &list_args, &mut stdout).await?;
|
||||
}
|
||||
WorktreesCommand::Reclaim(reclaim_args) => {
|
||||
// The daemon removes the directory and the store entry; the
|
||||
// branch is never deleted — worth saying out loud (#86).
|
||||
let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?;
|
||||
match entry {
|
||||
Some(wt) => println!(
|
||||
"reclaimed {} ({})\nbranch '{}' kept",
|
||||
wt.id,
|
||||
wt.path.display(),
|
||||
wt.branch
|
||||
),
|
||||
None => println!("reclaimed {}\nbranch kept", reclaim_args.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
|
||||
const CTRL_Q: u8 = 0x11;
|
||||
|
||||
/// #83: say "this will be refused" before the operator commits to it, in the
|
||||
/// same words [`maestro_protocol::InTheWay`] uses everywhere else.
|
||||
async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
|
||||
let Ok(sessions) = daemon.list_sessions().await else {
|
||||
return;
|
||||
};
|
||||
if let Some(b) = sessions
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.and_then(|s| s.blocked_work)
|
||||
{
|
||||
if !b.is_clear() {
|
||||
eprintln!(
|
||||
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client pointed at the daemon's API. That surface needs no auth —
|
||||
/// reaching `bind_addr` is the authorization.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn client(config_path: &Path, override_url: Option<String>) -> Result<DaemonClient, CliError> {
|
||||
let cfg = MaestroConfigFile::load(config_path)?;
|
||||
Ok(DaemonClient::new(cfg.resolve_daemon_url(override_url)))
|
||||
}
|
||||
@@ -1,174 +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 reqwest::header::{HeaderMap, HeaderValue, COOKIE, SET_COOKIE};
|
||||
use reqwest::{Method, Response, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::{CliConfig, ConfigError};
|
||||
|
||||
const COOKIE_NAME: &str = "maestro_session";
|
||||
|
||||
#[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),
|
||||
}
|
||||
|
||||
/// 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 = reqwest::Client::builder()
|
||||
.user_agent(concat!("maestro-cli/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.expect("build reqwest 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_value(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 })
|
||||
}
|
||||
|
||||
/// Extract `name=value` from a `Set-Cookie:` value. Ignores attributes.
|
||||
pub fn extract_cookie_value<'a>(raw: &'a str, name: &str) -> Option<&'a str> {
|
||||
let first = raw.split(';').next()?.trim();
|
||||
let (k, v) = first.split_once('=')?;
|
||||
(k.trim() == name).then(|| v.trim())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_named_cookie_value() {
|
||||
let raw = "maestro_session=abc.def.ghi; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000";
|
||||
assert_eq!(
|
||||
extract_cookie_value(raw, "maestro_session"),
|
||||
Some("abc.def.ghi")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_other_cookie_name() {
|
||||
let raw = "other=1; HttpOnly";
|
||||
assert_eq!(extract_cookie_value(raw, "maestro_session"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
//! `maestro` CLI — thin client for the registry/daemon API.
|
||||
//!
|
||||
//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §"Wire protocol"
|
||||
//! and the registry's `client_api.rs` for the HTTP surface this client targets.
|
||||
//! `maestro` CLI — thin client for the local daemon's `/api/*` surface.
|
||||
|
||||
#![deny(missing_debug_implementations)]
|
||||
|
||||
pub mod attach;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod http;
|
||||
pub mod dispatch;
|
||||
pub mod sessions;
|
||||
pub mod worktrees;
|
||||
|
||||
pub use dispatch::{dispatch, CliError};
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
//! `maestro` — CLI binary entry point.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use maestro_cli::attach::{parse_detach_key, run_attach};
|
||||
use maestro_cli::cli::{Cli, Command};
|
||||
use maestro_cli::config::{default_config_path, CliConfig, ConfigError};
|
||||
use maestro_cli::http::RegistryClient;
|
||||
use maestro_cli::sessions::{fetch_connect_info, run_kill, run_list, run_spawn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
init_tracing(cli.verbose);
|
||||
|
||||
match dispatch(cli).await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("maestro: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tracing(verbose: u8) {
|
||||
let filter = match (std::env::var_os("RUST_LOG"), verbose) {
|
||||
(Some(_), _) => {
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"))
|
||||
}
|
||||
(None, 0) => EnvFilter::new("warn"),
|
||||
(None, 1) => EnvFilter::new("info"),
|
||||
(None, 2) => EnvFilter::new("debug"),
|
||||
(None, _) => EnvFilter::new("trace"),
|
||||
};
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(clippy::result_large_err)]
|
||||
enum CliError {
|
||||
#[error("config: {0}")]
|
||||
Config(#[from] ConfigError),
|
||||
#[error("{0}")]
|
||||
Http(#[from] maestro_cli::http::HttpError),
|
||||
#[error("{0}")]
|
||||
Attach(#[from] maestro_cli::attach::AttachError),
|
||||
#[error("{0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn dispatch(cli: Cli) -> Result<(), CliError> {
|
||||
let config_path = match cli.config.clone() {
|
||||
Some(p) => p,
|
||||
None => default_config_path()?,
|
||||
};
|
||||
|
||||
// `set-registry` is the only command that writes config first and skips
|
||||
// the registry-required path below.
|
||||
if let Command::SetRegistry(args) = &cli.command {
|
||||
let url = args.url.trim_end_matches('/');
|
||||
CliConfig::save_registry_url(&config_path, url)?;
|
||||
println!("registry URL saved to {}", config_path.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client = build_client(cli.registry_url.clone(), &config_path)?;
|
||||
|
||||
match cli.command {
|
||||
Command::List(args) => {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
run_list(&client, &args, &mut stdout).await?;
|
||||
}
|
||||
Command::Spawn(args) => {
|
||||
let attach_after = args.attach;
|
||||
let session = run_spawn(&client, args).await?;
|
||||
println!("spawned {}", session.id);
|
||||
if attach_after {
|
||||
let info = fetch_connect_info(&client, session.id).await?;
|
||||
let detach = 0x11; // Ctrl-q
|
||||
run_attach(info, session.id, detach).await?;
|
||||
}
|
||||
}
|
||||
Command::Attach(args) => {
|
||||
let info = fetch_connect_info(&client, args.session_id).await?;
|
||||
let detach = parse_detach_key(&args.detach_key)?;
|
||||
run_attach(info, args.session_id, detach).await?;
|
||||
}
|
||||
Command::Kill(args) => {
|
||||
run_kill(&client, &args).await?;
|
||||
println!("killed {}", args.session_id);
|
||||
}
|
||||
Command::SetRegistry(_) => unreachable!("handled above"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn build_client(
|
||||
override_url: Option<String>,
|
||||
config_path: &Path,
|
||||
) -> Result<RegistryClient, CliError> {
|
||||
let cfg = CliConfig::load(config_path)?;
|
||||
let url = override_url
|
||||
.or(cfg.registry_url.clone())
|
||||
.ok_or(ConfigError::NoRegistryUrl)?;
|
||||
Ok(RegistryClient::new(
|
||||
url,
|
||||
cfg.cookie,
|
||||
config_path.to_path_buf(),
|
||||
))
|
||||
}
|
||||
+173
-130
@@ -1,153 +1,155 @@
|
||||
//! `maestro list / spawn / kill` — registry HTTP calls + console formatting.
|
||||
//! `maestro list / spawn / kill` — daemon HTTP calls + console formatting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::{
|
||||
ConnectInfo, CreateSessionRequest, DaemonInfo, Session, SessionStatus, WorktreeSpec,
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
||||
SessionStatus, WorktreeSpec,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::cli::{KillArgs, ListArgs, SpawnArgs};
|
||||
use crate::http::{HttpError, RegistryClient};
|
||||
|
||||
/// `maestro list`
|
||||
pub async fn run_list(
|
||||
client: &RegistryClient,
|
||||
client: &DaemonClient,
|
||||
args: &ListArgs,
|
||||
out: &mut impl Write,
|
||||
) -> Result<(), HttpError> {
|
||||
let mut sessions: Vec<Session> = client.get_json("/api/sessions").await?;
|
||||
let daemons: Vec<DaemonInfo> = client
|
||||
.get_json::<Vec<DaemonInfo>>("/api/daemons")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if let Some(d) = &args.daemon {
|
||||
sessions.retain(|s| &s.daemon_id == d);
|
||||
}
|
||||
) -> Result<(), ClientError> {
|
||||
let sessions = client.list_sessions().await?;
|
||||
let info = client.daemon_info().await.unwrap_or_default();
|
||||
|
||||
if args.json {
|
||||
let body = serde_json::json!({ "sessions": sessions, "daemons": daemons });
|
||||
let body = serde_json::json!({ "sessions": sessions });
|
||||
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
print_session_table(out, &sessions, &daemons);
|
||||
print_session_table(out, &sessions, &info);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `maestro spawn`
|
||||
pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result<Session, HttpError> {
|
||||
pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session, ClientError> {
|
||||
let mut env: HashMap<String, String> = HashMap::new();
|
||||
for (k, v) in args.env.iter() {
|
||||
env.insert(k.clone(), v.clone());
|
||||
}
|
||||
|
||||
let mut metadata: HashMap<String, Value> = HashMap::new();
|
||||
if let Some(label) = args.label.as_deref() {
|
||||
metadata.insert("label".into(), Value::String(label.into()));
|
||||
}
|
||||
let metadata: HashMap<String, Value> = HashMap::new();
|
||||
|
||||
let worktree = match (args.worktree_from.as_ref(), args.branch.as_ref()) {
|
||||
(Some(repo), Some(branch)) => Some(WorktreeSpec {
|
||||
base_repo: repo.clone(),
|
||||
branch: branch.clone(),
|
||||
cleanup_on_kill: args.cleanup_on_kill,
|
||||
create_branch: !args.existing_branch,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let body = CreateSessionRequest {
|
||||
daemon_id: args.daemon.clone(),
|
||||
cwd: args.cwd.clone(),
|
||||
command: args.command.clone(),
|
||||
env,
|
||||
metadata,
|
||||
worktree,
|
||||
};
|
||||
let session: Session = client.post_json("/api/sessions", &body).await?;
|
||||
Ok(session)
|
||||
client.create_session(&body).await
|
||||
}
|
||||
|
||||
/// `maestro kill`
|
||||
pub async fn run_kill(client: &RegistryClient, args: &KillArgs) -> Result<(), HttpError> {
|
||||
client
|
||||
.delete(&format!("/api/sessions/{}", args.session_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/sessions/:id/connect-info`
|
||||
pub async fn fetch_connect_info(
|
||||
client: &RegistryClient,
|
||||
session_id: Uuid,
|
||||
) -> Result<ConnectInfo, HttpError> {
|
||||
client
|
||||
.get_json(&format!("/api/sessions/{session_id}/connect-info"))
|
||||
.await
|
||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||
/// the work went.
|
||||
pub async fn run_kill(
|
||||
client: &DaemonClient,
|
||||
args: &KillArgs,
|
||||
) -> Result<maestro_protocol::KillOutcome, ClientError> {
|
||||
client.kill_session(args.session_id, args.force).await
|
||||
}
|
||||
|
||||
#[allow(clippy::write_literal)]
|
||||
fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[DaemonInfo]) {
|
||||
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
|
||||
if sessions.is_empty() {
|
||||
if daemons.is_empty() {
|
||||
writeln!(out, "no sessions, no daemons connected").ok();
|
||||
} else {
|
||||
writeln!(
|
||||
out,
|
||||
"no sessions ({} daemon{} connected)",
|
||||
daemons.len(),
|
||||
if daemons.len() == 1 { "" } else { "s" }
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
writeln!(out, "no sessions").ok();
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by daemon, stable order.
|
||||
let mut by_daemon: Vec<(String, Vec<&Session>)> = Vec::new();
|
||||
for s in sessions {
|
||||
if let Some(slot) = by_daemon.iter_mut().find(|(d, _)| d == &s.daemon_id) {
|
||||
slot.1.push(s);
|
||||
} else {
|
||||
by_daemon.push((s.daemon_id.clone(), vec![s]));
|
||||
}
|
||||
}
|
||||
for (_, group) in &mut by_daemon {
|
||||
group.sort_by_key(|s| s.created_at);
|
||||
}
|
||||
let mut sorted: Vec<&Session> = sessions.iter().collect();
|
||||
sorted.sort_by_key(|s| s.created_at);
|
||||
|
||||
for (daemon_id, group) in &by_daemon {
|
||||
let hostname = daemons
|
||||
.iter()
|
||||
.find(|d| &d.daemon_id == daemon_id)
|
||||
.map(|d| d.hostname.as_str())
|
||||
.unwrap_or("?");
|
||||
writeln!(out, "\n{daemon_id} ({hostname})").ok();
|
||||
if !info.hostname.is_empty() {
|
||||
let warn = if maestro_protocol::is_compatible(&info.version) {
|
||||
""
|
||||
} else {
|
||||
" (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} {:<20} {}",
|
||||
"ID", "STATUS", "LABEL", "COMMAND"
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
)
|
||||
.ok();
|
||||
for s in group {
|
||||
let label = s
|
||||
.metadata
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-");
|
||||
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(),
|
||||
};
|
||||
// #83: what an unforced kill would be refused for, in InTheWay's own
|
||||
// words; blank while nothing blocks (or hasn't been probed yet).
|
||||
let blocked = blocked_label(s);
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<20} {}",
|
||||
s.id, status, label, s.command
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, activity, dir, command
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`.
|
||||
/// Empty unless something actually blocks — "no unsaved work" would just be
|
||||
/// noise on every row.
|
||||
pub(crate) fn blocked_label(s: &Session) -> String {
|
||||
s.blocked_work
|
||||
.filter(|b| !b.is_clear())
|
||||
.map(|b| b.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
|
||||
/// UI's abbreviation so CLI and browser show the same directory shorthand.
|
||||
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
||||
let p = path.to_string_lossy();
|
||||
if !home.is_empty() {
|
||||
if p == home {
|
||||
return "~".into();
|
||||
}
|
||||
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
|
||||
return format!("~/{rest}");
|
||||
}
|
||||
}
|
||||
p.into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -156,76 +158,117 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
|
||||
use serde_json::Value;
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn session(daemon: &str, label: &str, command: &str, id: u128) -> Session {
|
||||
let mut meta = HashMap::new();
|
||||
meta.insert("label".into(), Value::String(label.into()));
|
||||
use super::DaemonInfo;
|
||||
|
||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||
Session {
|
||||
id: Uuid::from_u128(id),
|
||||
daemon_id: daemon.into(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
cwd: PathBuf::from(cwd),
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: meta,
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_grouped_table() {
|
||||
let sessions = vec![
|
||||
session("laptop", "personal/maestro", "claude", 1),
|
||||
session("vm1", "work/api", "bash", 2),
|
||||
session("laptop", "scratch", "nvim", 3),
|
||||
];
|
||||
let daemons = vec![
|
||||
fn info(home: &str) -> DaemonInfo {
|
||||
DaemonInfo {
|
||||
daemon_id: "laptop".into(),
|
||||
hostname: "naps-laptop".into(),
|
||||
version: "0.1.0".into(),
|
||||
daemon_url: "ws://10.0.0.1:7070".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: home.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_table() {
|
||||
let sessions = vec![
|
||||
session("/home/naps/maestro", "claude", 1),
|
||||
session("/srv/work/api", "bash", 2),
|
||||
];
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &sessions, &daemons);
|
||||
super::print_session_table(&mut buf, &sessions, &info("/home/naps"));
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("laptop (naps-laptop)"), "{s}");
|
||||
assert!(s.contains("vm1 (vm1.lan)"), "{s}");
|
||||
assert!(s.contains("personal/maestro"), "{s}");
|
||||
assert!(s.contains("naps-laptop"), "{s}");
|
||||
// Home dir collapses to ~ in the DIR column.
|
||||
assert!(s.contains("~/maestro"), "{s}");
|
||||
// Outside the home dir → path shown verbatim.
|
||||
assert!(s.contains("/srv/work/api"), "{s}");
|
||||
assert!(s.contains("running"), "{s}");
|
||||
assert!(s.contains("ACTIVITY"), "{s}");
|
||||
assert!(s.contains("DIR"), "{s}");
|
||||
assert!(s.contains("idle"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sessions_with_no_daemons() {
|
||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||
s.current_cwd = Some(PathBuf::from("/home/naps/maestro/crates/maestro-cli"));
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[], &[]);
|
||||
assert_eq!(
|
||||
String::from_utf8(buf).unwrap(),
|
||||
"no sessions, no daemons connected\n"
|
||||
super::print_session_table(&mut buf, &[s], &info("/home/naps"));
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_activity_and_foreground() {
|
||||
let mut s1 = session("/tmp", "bash", 1);
|
||||
s1.activity = ActivityState::AwaitingInput;
|
||||
s1.foreground = Some("claude".into()); // differs from spawn command
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[s1], &info(""));
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(out.contains("awaiting"), "{out}");
|
||||
// Live foreground surfaces next to the spawn command.
|
||||
assert!(out.contains("bash [claude]"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sessions() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[], &info(""));
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
|
||||
}
|
||||
|
||||
/// #83: a blocked session shows what an unforced kill would be refused
|
||||
/// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing.
|
||||
#[test]
|
||||
fn shows_the_blocked_work_summary_only_when_something_blocks() {
|
||||
use maestro_protocol::InTheWay;
|
||||
|
||||
let mut blocked = session("/tmp", "claude", 1);
|
||||
blocked.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 3,
|
||||
unpushed_commits: 2,
|
||||
..InTheWay::default()
|
||||
});
|
||||
let mut clear = session("/tmp", "bash", 2);
|
||||
clear.blocked_work = Some(InTheWay::default());
|
||||
let unprobed = session("/tmp", "zsh", 3);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info(""));
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(
|
||||
out.contains("3 uncommitted files, 2 unpushed commits"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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(),
|
||||
}];
|
||||
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}");
|
||||
// Clear and unprobed rows stay blank in the BLOCKED column.
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap();
|
||||
for line in &lines[header + 1..] {
|
||||
if !line.contains("uncommitted files") {
|
||||
assert!(!line.contains("unsaved work"), "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! `maestro worktrees list / reclaim` — the retained-store surface (#86).
|
||||
//!
|
||||
//! `reclaim` removes one directory; it never deletes the branch, which is
|
||||
//! said out loud in the output because "reclaim" otherwise sounds like it
|
||||
//! takes the commits too.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
||||
|
||||
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
||||
use crate::sessions::abbreviate_dir;
|
||||
|
||||
/// `maestro worktrees list`
|
||||
pub async fn run_list(
|
||||
client: &DaemonClient,
|
||||
args: &ListWorktreesArgs,
|
||||
out: &mut impl Write,
|
||||
) -> Result<(), ClientError> {
|
||||
let worktrees = client.list_worktrees().await?;
|
||||
let info = client.daemon_info().await.unwrap_or_default();
|
||||
|
||||
if args.json {
|
||||
let body = serde_json::json!({ "worktrees": worktrees });
|
||||
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
print_worktree_table(out, &worktrees, &info);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `maestro worktrees reclaim <id>`
|
||||
///
|
||||
/// Returns the entry as listed just before the reclaim, when it was found in
|
||||
/// the listing, so the confirmation can name what was removed.
|
||||
pub async fn run_reclaim(
|
||||
client: &DaemonClient,
|
||||
args: &ReclaimArgs,
|
||||
) -> Result<Option<RetainedWorktree>, ClientError> {
|
||||
let listed = client
|
||||
.list_worktrees()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|w| w.id == args.id);
|
||||
client.reclaim_worktree(args.id).await?;
|
||||
Ok(listed)
|
||||
}
|
||||
|
||||
fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) {
|
||||
if worktrees.is_empty() {
|
||||
writeln!(out, "no retained worktrees").ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect();
|
||||
sorted.sort_by_key(|w| w.retained_at);
|
||||
|
||||
if !info.hostname.is_empty() {
|
||||
let warn = if maestro_protocol::is_compatible(&info.version) {
|
||||
""
|
||||
} else {
|
||||
" (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} {:<24} {:<28} {:<24} IN THE WAY",
|
||||
"ID", "BRANCH", "PATH", "REASON"
|
||||
)
|
||||
.ok();
|
||||
for w in sorted {
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<24} {:<28} {:<24} {}",
|
||||
w.id,
|
||||
w.branch,
|
||||
abbreviate_dir(&w.path, &info.home_dir),
|
||||
reason_label(w.reason),
|
||||
blocker_label(&w.in_the_way),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
// The branch survives every reclaim — see docs/WORKTREES.md.
|
||||
writeln!(
|
||||
out,
|
||||
"\nreclaim removes the directory only; branches are never deleted"
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str {
|
||||
match reason {
|
||||
maestro_protocol::RetainedReason::ForceKilled => "force-killed",
|
||||
maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
|
||||
}
|
||||
}
|
||||
|
||||
/// The same breakdown that blocks an unforced kill, as words: what is still
|
||||
/// in the way, or `clear` when the work has since been committed and pushed.
|
||||
fn blocker_label(in_the_way: &InTheWay) -> String {
|
||||
if in_the_way.is_clear() {
|
||||
return "clear".into();
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if in_the_way.uncommitted_files > 0 {
|
||||
parts.push(format!("{} uncommitted", in_the_way.uncommitted_files));
|
||||
}
|
||||
if in_the_way.untracked_files > 0 {
|
||||
parts.push(format!("{} untracked", in_the_way.untracked_files));
|
||||
}
|
||||
if in_the_way.unpushed_commits > 0 {
|
||||
parts.push(format!("{} unpushed", in_the_way.unpushed_commits));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
|
||||
RetainedWorktree {
|
||||
id: Uuid::from_u128(id),
|
||||
path: PathBuf::from(path),
|
||||
base_repo: PathBuf::from("/home/naps/maestro"),
|
||||
branch: branch.into(),
|
||||
reason: RetainedReason::ForceKilled,
|
||||
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
||||
in_the_way: InTheWay::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn info(home: &str) -> DaemonInfo {
|
||||
DaemonInfo {
|
||||
hostname: "naps-laptop".into(),
|
||||
home_dir: home.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_table() {
|
||||
let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1);
|
||||
w.in_the_way = InTheWay {
|
||||
uncommitted_files: 2,
|
||||
untracked_files: 1,
|
||||
unpushed_commits: 3,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(&mut buf, &[w], &info("/home/naps"));
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("naps-laptop"), "{s}");
|
||||
assert!(s.contains("feat/login"), "{s}");
|
||||
// Home dir collapses to ~ in the PATH column, like sessions' DIR.
|
||||
assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}");
|
||||
assert!(s.contains("force-killed"), "{s}");
|
||||
assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}");
|
||||
assert!(s.contains("IN THE WAY"), "{s}");
|
||||
}
|
||||
|
||||
/// Contract rule, visible to the operator: reclaiming a tree must not
|
||||
/// throw away commits on its branch.
|
||||
#[test]
|
||||
fn says_the_branch_survives() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(
|
||||
&mut buf,
|
||||
&[worktree("feat/x", "/repo/worktrees/feat/x", 1)],
|
||||
&info(""),
|
||||
);
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("never deleted"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_tree_says_clear() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(
|
||||
&mut buf,
|
||||
&[worktree("main-ish", "/repo/worktrees/main-ish", 1)],
|
||||
&info(""),
|
||||
);
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("clear"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(&mut buf, &[], &info(""));
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 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(), false)
|
||||
.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, false)
|
||||
.await
|
||||
.expect("kill");
|
||||
assert_eq!(mock.session_count(), 0);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dirs.workspace = true
|
||||
maestro-protocol.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
+457
-329
@@ -4,40 +4,33 @@
|
||||
//! `$MAESTRO_CONFIG`) holds settings for every Maestro binary:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [shared]
|
||||
//! registry_url = "https://maestro.example.com"
|
||||
//! token = "<shared secret>"
|
||||
//!
|
||||
//! [cli]
|
||||
//! cookie = "<jwt cookie>"
|
||||
//! daemon_url = "http://127.0.0.1:7070"
|
||||
//!
|
||||
//! [daemon]
|
||||
//! daemon_url = "ws://laptop.lan:7070"
|
||||
//! bind_addr = "127.0.0.1:7070"
|
||||
//!
|
||||
//! [registry]
|
||||
//! [web]
|
||||
//! bind_addr = "127.0.0.1:8080"
|
||||
//! ```
|
||||
//!
|
||||
//! The CLI consumes `[shared].registry_url` as its HTTP base; the daemon
|
||||
//! derives its WS endpoint with [`derive_daemon_ws_url`] (scheme swap +
|
||||
//! `/daemon` suffix).
|
||||
//!
|
||||
//! This crate owns the file schema, path resolution, atomic save, and the
|
||||
//! URL derivation helper. Each binary applies its own env-var precedence and
|
||||
//! defaults on top of [`MaestroConfigFile`].
|
||||
//! This crate owns the file schema, path resolution, and atomic save. Each
|
||||
//! binary applies its own env-var precedence and defaults on top of
|
||||
//! [`MaestroConfigFile`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
|
||||
pub const DIR_NAME: &str = "maestro";
|
||||
/// File name inside [`DIR_NAME`].
|
||||
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)]
|
||||
pub enum ConfigError {
|
||||
@@ -49,218 +42,262 @@ pub enum ConfigError {
|
||||
TomlEncode(#[from] toml::ser::Error),
|
||||
#[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")]
|
||||
NoConfigDir,
|
||||
#[error(
|
||||
"maestro config not found at {}\n\
|
||||
Run `maestrod configure` to create it. \
|
||||
(Legacy ~/.config/maestrod/config.toml and ~/.config/maestro/{{cli,registry}}.toml \
|
||||
are no longer read.)",
|
||||
path.display()
|
||||
)]
|
||||
MissingFile { path: PathBuf },
|
||||
#[error("invalid registry_url {input:?}: {reason}")]
|
||||
InvalidRegistryUrl { input: String, reason: String },
|
||||
#[error("invalid {env} ({input:?}): {source}")]
|
||||
InvalidBindAddr {
|
||||
env: &'static str,
|
||||
input: String,
|
||||
source: std::net::AddrParseError,
|
||||
},
|
||||
}
|
||||
|
||||
/// On-disk representation of the unified config. Every section is optional
|
||||
/// so a partial file (e.g. just `[shared]` + `[registry]` for a registry
|
||||
/// host) is valid.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
/// so a partial file (e.g. just `[daemon]`) is valid.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
pub struct MaestroConfigFile {
|
||||
#[serde(default, skip_serializing_if = "SharedSection::is_empty")]
|
||||
pub shared: SharedSection,
|
||||
#[serde(default, skip_serializing_if = "CliSection::is_empty")]
|
||||
#[serde(default)]
|
||||
pub cli: CliSection,
|
||||
#[serde(default, skip_serializing_if = "DaemonSection::is_empty")]
|
||||
#[serde(default)]
|
||||
pub daemon: DaemonSection,
|
||||
#[serde(default, skip_serializing_if = "RegistrySection::is_empty")]
|
||||
pub registry: RegistrySection,
|
||||
#[serde(default, skip_serializing_if = "WebSection::is_empty")]
|
||||
#[serde(default)]
|
||||
pub web: WebSection,
|
||||
#[serde(default)]
|
||||
pub tui: TuiSection,
|
||||
#[serde(default)]
|
||||
pub spawn: SpawnSection,
|
||||
}
|
||||
|
||||
/// Fields consumed by more than one binary (CLI + daemon, daemon + registry).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SharedSection {
|
||||
/// Registry base URL, e.g. `https://maestro.example.com`. CLI uses it
|
||||
/// as-is for HTTP; the daemon derives its WS endpoint via
|
||||
/// [`derive_daemon_ws_url`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub registry_url: Option<String>,
|
||||
/// Shared HS256 secret. Registry signs JWTs with it, daemons verify
|
||||
/// (and authenticate registry calls) with it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
impl SharedSection {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.registry_url.is_none() && self.token.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
pub struct CliSection {
|
||||
/// Persisted `maestro_session` cookie value (a JWT). Opaque to the CLI.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cookie: Option<String>,
|
||||
}
|
||||
|
||||
impl CliSection {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.cookie.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")]
|
||||
/// HTTP base URL of the daemon the CLI talks to, e.g.
|
||||
/// `http://127.0.0.1:7070`. When unset, the CLI derives it from
|
||||
/// `[daemon].bind_addr` if a daemon is configured locally, else falls
|
||||
/// back to `http://127.0.0.1:7070`.
|
||||
#[serde(default)]
|
||||
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>,
|
||||
/// Root for everything daemon-local: sidecars, FIFOs, etc.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub state_dir: Option<PathBuf>,
|
||||
/// Root for ephemeral worktrees the daemon creates on `CreateSession`
|
||||
/// with a `worktree` spec.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Override root for ephemeral worktrees the daemon creates on
|
||||
/// `CreateSession` with a `worktree` spec. Unset by default: worktrees
|
||||
/// then live inside their base repo at `<repo>/worktrees/<branch>` (#56).
|
||||
#[serde(default)]
|
||||
pub worktree_root: Option<PathBuf>,
|
||||
/// Operator-set perimeter id (e.g. `"wg-naps62"`). When two daemons share
|
||||
/// a perimeter, peer-to-peer attach can use the T2 transport. Advertised
|
||||
/// to the registry in heartbeats. See connection-orchestration spec.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub perimeter_id: Option<String>,
|
||||
/// Daemon's reachable address on the declared perimeter (e.g.
|
||||
/// `"10.0.0.7:7070"`). Advertised in heartbeats; the registry hands it
|
||||
/// back to peer daemons as a T2 dial target.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub private_addr: Option<String>,
|
||||
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
|
||||
/// receives this much of the prior PTY output before the live stream.
|
||||
/// `None` ⇒ daemon default (~64 KB); `Some(0)` disables replay.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
pub pty_replay_bytes: Option<usize>,
|
||||
/// `[daemon.policy]` — per-daemon attach policy (NAP-247).
|
||||
#[serde(default, skip_serializing_if = "DaemonPolicySection::is_empty")]
|
||||
pub policy: DaemonPolicySection,
|
||||
}
|
||||
|
||||
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.policy.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// `[daemon.policy]` — application-layer access control complementing network
|
||||
/// ACLs. Default deny: an empty `allow_inbound` rejects every cross-daemon
|
||||
/// attach. Local attach (no JWT, same daemon) always bypasses.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DaemonPolicySection {
|
||||
/// daemon-ids permitted to broker-attach into this daemon's sessions.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub allow_inbound: Vec<String>,
|
||||
}
|
||||
|
||||
impl DaemonPolicySection {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.allow_inbound.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
/// `TMUX_TMPDIR` the daemon exports for every `tmux` invocation (NAP-234).
|
||||
/// When unset, the daemon defaults to `$XDG_RUNTIME_DIR` so that bare
|
||||
/// `tmux ls` in a user shell (which usually has the same env var) sees
|
||||
/// 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
|
||||
/// enable `loginctl enable-linger`.
|
||||
#[serde(default)]
|
||||
pub tmux_tmpdir: Option<PathBuf>,
|
||||
/// Recency cap in days for the durable spawn history that backs the spawn
|
||||
/// form's "recent dirs" picker (#24). `None` ⇒ daemon default (30 days);
|
||||
/// `Some(0)` disables history entirely (no log written, no recents read).
|
||||
#[serde(default)]
|
||||
pub spawn_history_days: Option<u32>,
|
||||
}
|
||||
|
||||
/// `[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 {
|
||||
/// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a
|
||||
/// perimeter-facing address (LAN/WG) to share with other devices on a
|
||||
/// trusted segment; do NOT bind a public address without adding auth.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Address `maestro-web` binds. Default `127.0.0.1:8080`. Bind a LAN/VPN
|
||||
/// address to share with your own devices on a trusted segment; there is no
|
||||
/// auth layer, so never bind a public address.
|
||||
#[serde(default)]
|
||||
pub bind_addr: Option<SocketAddr>,
|
||||
/// HTTP base URL of the co-located daemon `maestro-web` proxies to.
|
||||
/// Default `http://127.0.0.1:7070`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
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).
|
||||
///
|
||||
/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships
|
||||
/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are
|
||||
/// applied **over** the defaults, so a partial override is enough.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
pub struct TuiSection {
|
||||
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
|
||||
#[serde(default)]
|
||||
pub leader: Option<String>,
|
||||
/// Maximum interval between chord keys before a held prefix is dropped.
|
||||
/// Default `1000`.
|
||||
#[serde(default)]
|
||||
pub chord_timeout_ms: Option<u64>,
|
||||
/// Normal-mode bindings: canonical input → action string. Applied over
|
||||
/// the shipped defaults.
|
||||
#[serde(default)]
|
||||
pub keymap_normal: BTreeMap<String, String>,
|
||||
/// Insert-mode bindings, same semantics as `keymap_normal`.
|
||||
#[serde(default)]
|
||||
pub keymap_insert: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// `[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
|
||||
/// it via the daemon's catalog advertisement (#26), the TUI adopts it later.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
pub struct SpawnSection {
|
||||
/// Command presets offered as one-click tiles. When present, this list
|
||||
/// **replaces** the [`builtin_presets`] defaults (the implicit `custom`
|
||||
/// escape hatch is still appended by [`SpawnSection::resolve`]); when
|
||||
/// absent, the built-ins are used. `presets = []` is "present but empty" —
|
||||
/// it resolves to just the `custom` tile.
|
||||
#[serde(default)]
|
||||
pub presets: Option<Vec<SpawnPreset>>,
|
||||
/// Root directories the daemon scans for git repositories (#54), `~`
|
||||
/// expanded by the daemon against its own home. When absent the daemon
|
||||
/// scans `["~"]`.
|
||||
#[serde(default)]
|
||||
pub project_roots: Option<Vec<String>>,
|
||||
/// How many directory levels below each [`Self::project_roots`] entry the
|
||||
/// scanner descends (#54). When absent the daemon uses `3`.
|
||||
#[serde(default)]
|
||||
pub project_scan_depth: Option<u32>,
|
||||
}
|
||||
|
||||
impl SpawnSection {
|
||||
/// The presets to show, in order, with the implicit `custom` tile appended.
|
||||
///
|
||||
/// Resolution: configured `presets` if present, else [`builtin_presets`];
|
||||
/// then [`custom_preset`] is appended unconditionally as the "none of the
|
||||
/// above" free-text escape hatch. The configured (or built-in) list is
|
||||
/// validated first — see [`SpawnConfigError`].
|
||||
pub fn resolve(&self) -> Result<Vec<SpawnPreset>, SpawnConfigError> {
|
||||
let mut list = match &self.presets {
|
||||
Some(p) => p.clone(),
|
||||
None => builtin_presets(),
|
||||
};
|
||||
validate_presets(&list)?;
|
||||
list.push(custom_preset());
|
||||
Ok(list)
|
||||
}
|
||||
}
|
||||
|
||||
/// A single spawn-form command preset: a stable `id`, a human `label` for the
|
||||
/// tile, and the `command` to run. The implicit `custom` tile (see
|
||||
/// [`custom_preset`]) reuses this shape with an empty `command` the client
|
||||
/// fills from free-text input.
|
||||
///
|
||||
/// Defined in `maestro-protocol` because it also rides `GET /api/info`; the
|
||||
/// on-disk `[spawn].presets` schema and the wire payload are one type.
|
||||
pub use maestro_protocol::SpawnPreset;
|
||||
|
||||
/// Reserved id for the implicit free-text tile. A user-defined preset may not
|
||||
/// claim it (see [`SpawnConfigError::ReservedCustomId`]) since it's always
|
||||
/// appended by [`SpawnSection::resolve`].
|
||||
pub const CUSTOM_PRESET_ID: &str = "custom";
|
||||
|
||||
/// Built-in default presets used when `[spawn].presets` is absent.
|
||||
pub fn builtin_presets() -> Vec<SpawnPreset> {
|
||||
vec![
|
||||
SpawnPreset {
|
||||
id: "claude".into(),
|
||||
label: "Claude".into(),
|
||||
command: "claude".into(),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "opencode".into(),
|
||||
label: "opencode".into(),
|
||||
command: "opencode".into(),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "zsh".into(),
|
||||
label: "Shell".into(),
|
||||
command: "zsh".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// The implicit `custom` tile appended after the resolved presets. Its empty
|
||||
/// `command` is the signal to the client to take a free-text command instead.
|
||||
pub fn custom_preset() -> SpawnPreset {
|
||||
SpawnPreset {
|
||||
id: CUSTOM_PRESET_ID.into(),
|
||||
label: "Custom…".into(),
|
||||
command: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `[spawn].presets` list that violates the schema invariants.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum SpawnConfigError {
|
||||
#[error("spawn preset has an empty id")]
|
||||
EmptyId,
|
||||
#[error("spawn preset {id:?} has an empty command")]
|
||||
EmptyCommand { id: String },
|
||||
#[error("duplicate spawn preset id {id:?}")]
|
||||
DuplicateId { id: String },
|
||||
#[error(
|
||||
"spawn preset id {:?} is reserved for the implicit free-text tile",
|
||||
CUSTOM_PRESET_ID
|
||||
)]
|
||||
ReservedCustomId,
|
||||
}
|
||||
|
||||
/// Enforce: each `id` non-empty, not the reserved `custom`, unique; each
|
||||
/// `command` non-empty. Order of checks is per-field then cross-field so the
|
||||
/// first offending preset produces the most specific error.
|
||||
fn validate_presets(presets: &[SpawnPreset]) -> Result<(), SpawnConfigError> {
|
||||
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
for p in presets {
|
||||
if p.id.trim().is_empty() {
|
||||
return Err(SpawnConfigError::EmptyId);
|
||||
}
|
||||
if p.id == CUSTOM_PRESET_ID {
|
||||
return Err(SpawnConfigError::ReservedCustomId);
|
||||
}
|
||||
if p.command.trim().is_empty() {
|
||||
return Err(SpawnConfigError::EmptyCommand { id: p.id.clone() });
|
||||
}
|
||||
if !seen.insert(p.id.as_str()) {
|
||||
return Err(SpawnConfigError::DuplicateId { id: p.id.clone() });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl MaestroConfigFile {
|
||||
/// Strict load — error with [`ConfigError::MissingFile`] if absent.
|
||||
/// Load the file, or return an empty config if it doesn't exist. Every
|
||||
/// field has a default, so a missing file is never an error.
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigError> {
|
||||
if !path.exists() {
|
||||
return Err(ConfigError::MissingFile {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let bytes = std::fs::read(path)?;
|
||||
let s = std::str::from_utf8(&bytes).unwrap_or("");
|
||||
Ok(toml::from_str(s)?)
|
||||
}
|
||||
|
||||
/// Load if present, else return an empty config. Use for callers that
|
||||
/// want a default-on-missing read (e.g. CLI's `set-registry` bootstrap,
|
||||
/// registry's first-run token generation).
|
||||
pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
/// Daemon HTTP base a client should talk to.
|
||||
///
|
||||
/// Precedence: explicit `override_url` (flag or env), then
|
||||
/// `[cli].daemon_url`, then `http://<[daemon].bind_addr>`, then
|
||||
/// [`DEFAULT_DAEMON_URL`]. Always yields a value — reachability is decided
|
||||
/// by whether the connection succeeds.
|
||||
pub fn resolve_daemon_url(&self, override_url: Option<String>) -> String {
|
||||
override_url
|
||||
.or_else(|| self.cli.daemon_url.clone())
|
||||
.or_else(|| self.daemon.bind_addr.map(|a| format!("http://{a}")))
|
||||
.unwrap_or_else(|| DEFAULT_DAEMON_URL.to_string())
|
||||
}
|
||||
Self::load(path)
|
||||
}
|
||||
|
||||
/// Atomic write (tmp + rename). Creates parent dirs on demand.
|
||||
pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
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).
|
||||
@@ -269,197 +306,288 @@ pub fn default_config_path() -> Result<PathBuf, ConfigError> {
|
||||
Ok(base.join(DIR_NAME).join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// Derive the daemon's WS endpoint from the shared `registry_url`.
|
||||
///
|
||||
/// | input | output |
|
||||
/// |--------------------------|---------------------------------|
|
||||
/// | `https://r.example` | `wss://r.example/daemon` |
|
||||
/// | `http://r.example:8080` | `ws://r.example:8080/daemon` |
|
||||
/// | `http://r/some/prefix/` | `ws://r/some/prefix/daemon` |
|
||||
///
|
||||
/// Trailing slashes are normalised so the result never has `//daemon`.
|
||||
/// Any non-`http(s)` scheme returns [`ConfigError::InvalidRegistryUrl`].
|
||||
pub fn derive_daemon_ws_url(registry_url: &str) -> Result<String, ConfigError> {
|
||||
let trimmed = registry_url.trim_end_matches('/');
|
||||
if let Some(rest) = trimmed.strip_prefix("https://") {
|
||||
Ok(format!("wss://{rest}/daemon"))
|
||||
} else if let Some(rest) = trimmed.strip_prefix("http://") {
|
||||
Ok(format!("ws://{rest}/daemon"))
|
||||
} else {
|
||||
Err(ConfigError::InvalidRegistryUrl {
|
||||
input: registry_url.to_string(),
|
||||
reason: "must start with http:// or https://".into(),
|
||||
/// Parse a `SocketAddr` from a raw (usually env-sourced) string, tagging the
|
||||
/// failure with the originating env-var name so every binary surfaces the same
|
||||
/// `invalid <ENV> (...)` diagnostic. Shared by the daemon and web `bind_addr`
|
||||
/// resolution paths (NAP-273).
|
||||
pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigError> {
|
||||
raw.parse().map_err(|source| ConfigError::InvalidBindAddr {
|
||||
env,
|
||||
input: raw.to_string(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// XDG state directory for a Maestro component, e.g. `~/.local/state/<subdir>`
|
||||
/// (falling back to the data-local dir when `XDG_STATE_HOME`/`$HOME` are
|
||||
/// unset). The daemon passes `"maestro"`, `maestro-web` passes `"maestro-web"`
|
||||
/// (NAP-273).
|
||||
pub fn default_state_dir(subdir: &str) -> Result<PathBuf, ConfigError> {
|
||||
let base = dirs::state_dir()
|
||||
.or_else(dirs::data_local_dir)
|
||||
.ok_or(ConfigError::NoConfigDir)?;
|
||||
Ok(base.join(subdir))
|
||||
}
|
||||
/// Capacity of the session-event broadcast channel (`CatalogEvent`).
|
||||
pub const EVENTS_CAPACITY: usize = 256;
|
||||
/// Capacity of the per-session PTY byte broadcast channel.
|
||||
pub const PTY_CAPACITY: usize = 256;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_file_strict_load_errors_with_pointer_to_configure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let err = MaestroConfigFile::load(&path).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(matches!(err, ConfigError::MissingFile { .. }));
|
||||
assert!(msg.contains("maestrod configure"), "msg was: {msg}");
|
||||
assert!(msg.contains("Legacy"), "msg was: {msg}");
|
||||
fn parse_bind_addr_ok_and_err() {
|
||||
assert_eq!(
|
||||
parse_bind_addr("127.0.0.1:7070", "MAESTRO_X").unwrap(),
|
||||
"127.0.0.1:7070".parse().unwrap()
|
||||
);
|
||||
let err = parse_bind_addr("not-an-addr", "MAESTRO_X").unwrap_err();
|
||||
match err {
|
||||
ConfigError::InvalidBindAddr { env, input, .. } => {
|
||||
assert_eq!(env, "MAESTRO_X");
|
||||
assert_eq!(input, "not-an-addr");
|
||||
}
|
||||
other => panic!("expected InvalidBindAddr, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_load_or_default_returns_empty() {
|
||||
fn default_state_dir_appends_subdir() {
|
||||
// On any host with XDG_STATE_HOME / HOME set (CI included) this is Some.
|
||||
if let Ok(p) = default_state_dir("maestro-xyz") {
|
||||
assert!(p.ends_with("maestro-xyz"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_capacities_are_the_historical_defaults() {
|
||||
assert_eq!(EVENTS_CAPACITY, 256);
|
||||
assert_eq!(PTY_CAPACITY, 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_loads_as_empty_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let cfg = MaestroConfigFile::load_or_default(&path).unwrap();
|
||||
let cfg = MaestroConfigFile::load(&path).unwrap();
|
||||
assert_eq!(cfg, MaestroConfigFile::default());
|
||||
}
|
||||
|
||||
#[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()),
|
||||
},
|
||||
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),
|
||||
policy: DaemonPolicySection {
|
||||
allow_inbound: vec!["konishi".into()],
|
||||
},
|
||||
},
|
||||
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()),
|
||||
},
|
||||
};
|
||||
cfg.save(&path).unwrap();
|
||||
assert_eq!(MaestroConfigFile::load(&path).unwrap(), cfg);
|
||||
fn spawn_presets_default_to_builtins_plus_custom() {
|
||||
// Absent [spawn].presets → built-ins, with the implicit custom tile last.
|
||||
let resolved = SpawnSection::default().resolve().unwrap();
|
||||
let ids: Vec<&str> = resolved.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["claude", "opencode", "zsh", "custom"]);
|
||||
// The custom tile carries an empty command — the free-text signal.
|
||||
let custom = resolved.last().unwrap();
|
||||
assert_eq!(custom.label, "Custom…");
|
||||
assert!(custom.command.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_file_loads_with_section_defaults() {
|
||||
// Only [shared] + [registry] present (typical registry host).
|
||||
fn spawn_presets_override_replaces_builtins() {
|
||||
// Present list replaces the built-ins entirely; custom still appended.
|
||||
let section = SpawnSection {
|
||||
presets: Some(vec![SpawnPreset {
|
||||
id: "fish".into(),
|
||||
label: "fish".into(),
|
||||
command: "fish".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
let ids: Vec<String> = section
|
||||
.resolve()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|p| p.id)
|
||||
.collect();
|
||||
assert_eq!(ids, ["fish", "custom"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_presets_empty_list_resolves_to_just_custom() {
|
||||
// `presets = []` is present-but-empty: it replaces with nothing, leaving
|
||||
// only the implicit custom tile.
|
||||
let section = SpawnSection {
|
||||
presets: Some(vec![]),
|
||||
..Default::default()
|
||||
};
|
||||
let ids: Vec<String> = section
|
||||
.resolve()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|p| p.id)
|
||||
.collect();
|
||||
assert_eq!(ids, ["custom"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_presets_reject_empty_id_and_command() {
|
||||
let empty_id = SpawnSection {
|
||||
presets: Some(vec![SpawnPreset {
|
||||
id: " ".into(),
|
||||
label: "x".into(),
|
||||
command: "x".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId);
|
||||
|
||||
let empty_cmd = SpawnSection {
|
||||
presets: Some(vec![SpawnPreset {
|
||||
id: "x".into(),
|
||||
label: "x".into(),
|
||||
command: "".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
empty_cmd.resolve().unwrap_err(),
|
||||
SpawnConfigError::EmptyCommand { id: "x".into() }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_presets_reject_duplicate_and_reserved_id() {
|
||||
let dup = SpawnSection {
|
||||
presets: Some(vec![
|
||||
SpawnPreset {
|
||||
id: "a".into(),
|
||||
label: "a".into(),
|
||||
command: "a".into(),
|
||||
},
|
||||
SpawnPreset {
|
||||
id: "a".into(),
|
||||
label: "a2".into(),
|
||||
command: "a2".into(),
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
dup.resolve().unwrap_err(),
|
||||
SpawnConfigError::DuplicateId { id: "a".into() }
|
||||
);
|
||||
|
||||
let reserved = SpawnSection {
|
||||
presets: Some(vec![SpawnPreset {
|
||||
id: "custom".into(),
|
||||
label: "Custom".into(),
|
||||
command: "whatever".into(),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
reserved.resolve().unwrap_err(),
|
||||
SpawnConfigError::ReservedCustomId
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_section_parses_from_toml_table() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[shared]
|
||||
token = "abc"
|
||||
[spawn]
|
||||
presets = [
|
||||
{ id = "claude", label = "Claude", command = "claude" },
|
||||
{ id = "zsh", label = "Shell", command = "zsh" },
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = MaestroConfigFile::load(&path).unwrap();
|
||||
let ids: Vec<String> = cfg
|
||||
.spawn
|
||||
.resolve()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|p| p.id)
|
||||
.collect();
|
||||
assert_eq!(ids, ["claude", "zsh", "custom"]);
|
||||
}
|
||||
|
||||
[registry]
|
||||
#[test]
|
||||
fn spawn_project_scan_fields_default_to_none() {
|
||||
// Absent [spawn].project_* → None; the daemon applies its own defaults.
|
||||
assert_eq!(SpawnSection::default().project_roots, None);
|
||||
assert_eq!(SpawnSection::default().project_scan_depth, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_project_scan_fields_parse_from_toml() {
|
||||
let cfg = toml::from_str::<MaestroConfigFile>(
|
||||
r#"
|
||||
[spawn]
|
||||
project_roots = ["~/code", "/srv/work"]
|
||||
project_scan_depth = 5
|
||||
"#,
|
||||
)
|
||||
.expect("parse");
|
||||
assert_eq!(
|
||||
cfg.spawn.project_roots,
|
||||
Some(vec!["~/code".to_string(), "/srv/work".to_string()])
|
||||
);
|
||||
assert_eq!(cfg.spawn.project_scan_depth, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_file_loads_with_section_defaults() {
|
||||
// Only [daemon] present (typical single-host install).
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[daemon]
|
||||
bind_addr = "127.0.0.1:9090"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = MaestroConfigFile::load(&path).unwrap();
|
||||
assert_eq!(cfg.shared.token.as_deref(), Some("abc"));
|
||||
assert!(cfg.shared.registry_url.is_none());
|
||||
assert_eq!(cfg.cli, CliSection::default());
|
||||
assert_eq!(cfg.daemon, DaemonSection::default());
|
||||
assert_eq!(cfg.web, WebSection::default());
|
||||
assert_eq!(
|
||||
cfg.registry.bind_addr,
|
||||
cfg.daemon.bind_addr,
|
||||
Some("127.0.0.1:9090".parse().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_omits_empty_sections() {
|
||||
// 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}");
|
||||
fn file_with(body: &str) -> MaestroConfigFile {
|
||||
toml::from_str(body).expect("parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_atomic_creates_parent_dirs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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() {
|
||||
fn daemon_url_prefers_an_explicit_override() {
|
||||
let cfg = file_with("[cli]\ndaemon_url = \"http://from-file:1\"\n");
|
||||
assert_eq!(
|
||||
derive_daemon_ws_url("http://r.example").unwrap(),
|
||||
"ws://r.example/daemon"
|
||||
);
|
||||
assert_eq!(
|
||||
derive_daemon_ws_url("http://r.example:8080").unwrap(),
|
||||
"ws://r.example:8080/daemon"
|
||||
cfg.resolve_daemon_url(Some("http://override:2".into())),
|
||||
"http://override:2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_daemon_ws_url_swaps_https_to_wss() {
|
||||
assert_eq!(
|
||||
derive_daemon_ws_url("https://r.example").unwrap(),
|
||||
"wss://r.example/daemon"
|
||||
);
|
||||
fn daemon_url_falls_back_through_cli_then_bind_addr_then_default() {
|
||||
let from_cli = file_with("[cli]\ndaemon_url = \"http://from-cli:1\"\n");
|
||||
assert_eq!(from_cli.resolve_daemon_url(None), "http://from-cli:1");
|
||||
|
||||
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]
|
||||
fn derive_daemon_ws_url_normalises_trailing_slash() {
|
||||
assert_eq!(
|
||||
derive_daemon_ws_url("https://r.example/").unwrap(),
|
||||
"wss://r.example/daemon"
|
||||
fn cli_daemon_url_beats_a_locally_configured_bind_addr() {
|
||||
let cfg = file_with(
|
||||
"[cli]\ndaemon_url = \"http://explicit:1\"\n[daemon]\nbind_addr = \"10.0.0.1:7070\"\n",
|
||||
);
|
||||
assert_eq!(
|
||||
derive_daemon_ws_url("https://r.example/path/").unwrap(),
|
||||
"wss://r.example/path/daemon"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_daemon_ws_url_rejects_non_http_schemes() {
|
||||
let err = derive_daemon_ws_url("ws://r.example/daemon").unwrap_err();
|
||||
match err {
|
||||
ConfigError::InvalidRegistryUrl { input, .. } => {
|
||||
assert_eq!(input, "ws://r.example/daemon");
|
||||
}
|
||||
other => panic!("expected InvalidRegistryUrl, got {other:?}"),
|
||||
}
|
||||
assert_eq!(cfg.resolve_daemon_url(None), "http://explicit:1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,37 +5,34 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Per-host tmux wrapper + session orchestrator that talks to the registry"
|
||||
description = "Per-host tmux wrapper + session orchestrator"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "maestrod"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
dirs.workspace = true
|
||||
futures-util.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
libc.workspace = true
|
||||
maestro-config.workspace = true
|
||||
maestro-names.workspace = true
|
||||
maestro-protocol.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
urlencoding = "2"
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
urlencoding = "2"
|
||||
tempfile.workspace = true
|
||||
maestro-registry = { path = "../maestro-registry" }
|
||||
maestro-names.workspace = true
|
||||
# `test-util` enables `start_paused` for instant timeout tests (NAP-270).
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Activity-state inference combine logic (NAP-340).
|
||||
//!
|
||||
//! Pure decision from three structured signals — PTY quiescence, foreground
|
||||
//! process, and `/proc` block-state — so it's unit-testable without tmux or a
|
||||
//! live session. The orchestration (gathering the signals each liveness tick
|
||||
//! and emitting `SessionUpdated` on change) lives in [`crate::session`].
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use maestro_protocol::ActivityState;
|
||||
|
||||
/// PTY output more recent than this keeps a session `Active`. It must exceed the
|
||||
/// liveness tick (~2s, [`crate::session::DEFAULT_LIVENESS_INTERVAL`]) so a
|
||||
/// session emitting a chunk every tick stays `Active` instead of flapping
|
||||
/// Active↔Idle. This window *is* the debounce the issue calls for.
|
||||
pub const ACTIVE_WINDOW: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Combine the observed signals into an [`ActivityState`]:
|
||||
/// recent output ⇒ `Active`; else blocked on a terminal read ⇒ `AwaitingInput`;
|
||||
/// else `Idle`.
|
||||
pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
||||
if idle_for < ACTIVE_WINDOW {
|
||||
ActivityState::Active
|
||||
} else if awaiting_input {
|
||||
ActivityState::AwaitingInput
|
||||
} else {
|
||||
ActivityState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recent_output_is_active_regardless_of_block_state() {
|
||||
assert_eq!(
|
||||
decide(Duration::from_millis(100), false),
|
||||
ActivityState::Active
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Duration::from_millis(100), true),
|
||||
ActivityState::Active
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiescent_and_blocked_is_awaiting_input() {
|
||||
assert_eq!(
|
||||
decide(ACTIVE_WINDOW + Duration::from_secs(1), true),
|
||||
ActivityState::AwaitingInput
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiescent_and_not_blocked_is_idle() {
|
||||
assert_eq!(
|
||||
decide(ACTIVE_WINDOW + Duration::from_secs(1), false),
|
||||
ActivityState::Idle
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//! - Text frames carry [`maestro_protocol::ClientControl`] (Resize/Ping/Pong)
|
||||
//! - JWT is HS256, validated locally with no callback to the registry
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path as AxumPath, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
use axum::routing::any;
|
||||
use axum::Router;
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::ClientControl;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jwt::{JwtVerifier, PairAttachVerifier};
|
||||
use crate::session::SessionManager;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AttachState {
|
||||
pub manager: SessionManager,
|
||||
pub verifier: Arc<JwtVerifier>,
|
||||
/// Pair-scoped JWT verifier (NAP-247). When the legacy verifier fails
|
||||
/// (e.g. the token was minted as a pair-attach JWT for the broker path),
|
||||
/// we fall back to this one before rejecting the client outright. Local
|
||||
/// attaches always go through the legacy verifier.
|
||||
pub pair_verifier: Arc<PairAttachVerifier>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AttachParams {
|
||||
token: String,
|
||||
}
|
||||
|
||||
pub fn router(
|
||||
manager: SessionManager,
|
||||
verifier: JwtVerifier,
|
||||
pair_verifier: PairAttachVerifier,
|
||||
) -> Router {
|
||||
Router::new()
|
||||
.route("/sessions/:id/stream", any(attach_ws))
|
||||
.with_state(AttachState {
|
||||
manager,
|
||||
verifier: Arc::new(verifier),
|
||||
pair_verifier: Arc::new(pair_verifier),
|
||||
})
|
||||
}
|
||||
|
||||
async fn attach_ws(
|
||||
AxumPath(id): AxumPath<Uuid>,
|
||||
Query(params): Query<AttachParams>,
|
||||
State(state): State<AttachState>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
// Try the legacy `JwtClaims` shape first (browser/CLI direct attach). On
|
||||
// any failure, try the pair-scoped shape used by the home-daemon broker
|
||||
// (NAP-247). One — and only one — must accept.
|
||||
let legacy_err = match state.verifier.verify(¶ms.token) {
|
||||
Ok(claims) => {
|
||||
if claims.session_id != id {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
format!(
|
||||
"token is for session {} but path requested {}",
|
||||
claims.session_id, id
|
||||
),
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(e) => Some(e),
|
||||
};
|
||||
if let Some(legacy_err) = legacy_err {
|
||||
match state.pair_verifier.verify(¶ms.token, id) {
|
||||
Ok(sub) => {
|
||||
tracing::debug!(home_daemon = %sub, %id, "pair-scoped attach accepted");
|
||||
}
|
||||
Err(pair_err) => {
|
||||
// Both rejected — return the legacy error since most clients
|
||||
// still mint that shape today.
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
format!("legacy: {legacy_err}; pair: {pair_err}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !state.manager.has(id).await {
|
||||
return Err((StatusCode::NOT_FOUND, format!("no session {id}")));
|
||||
}
|
||||
let manager = state.manager.clone();
|
||||
Ok(ws.on_upgrade(move |socket| async move {
|
||||
let _ = serve_local(socket, manager, id).await;
|
||||
}))
|
||||
}
|
||||
|
||||
/// Pump a WebSocket against a local session's PTY. Reused by both the legacy
|
||||
/// `/sessions/:id/stream` endpoint (after JWT auth) and the new broker
|
||||
/// `WS /api/attach/:id` endpoint (when the session is local-owned — NAP-243).
|
||||
pub(crate) async fn serve_local(
|
||||
/// Pump a WebSocket against a session's PTY, behind `WS /api/attach/:id`.
|
||||
///
|
||||
/// The subscription is acquired by the handler *before* the upgrade and handed
|
||||
/// in here, so there is no window in which the session can disappear between
|
||||
/// the existence check and the subscribe.
|
||||
pub(crate) async fn serve(
|
||||
socket: WebSocket,
|
||||
manager: SessionManager,
|
||||
id: Uuid,
|
||||
replay: Vec<u8>,
|
||||
mut rx: broadcast::Receiver<Vec<u8>>,
|
||||
) -> Result<(), axum::Error> {
|
||||
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
|
||||
// screenful immediately instead of a blank terminal. The replay lock the
|
||||
// bridge held during snapshot+subscribe guarantees no byte appears both
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! `GET /api/branches` — local branches of one repository (#55).
|
||||
//!
|
||||
//! Spawning into a worktree on an *existing* branch needs to know which
|
||||
//! branches the repository has and which are taken: git refuses to check the
|
||||
//! same branch out in two worktrees at once. Each [`BranchInfo`] answers for
|
||||
//! one local branch — its name, whether it is the main checkout's HEAD, and
|
||||
//! which linked worktree (if any) holds it.
|
||||
//!
|
||||
//! Read-only, like [`crate::gitroot`]: two git probes (`for-each-ref`,
|
||||
//! `worktree list --porcelain`) with no write path anywhere. A path outside
|
||||
//! any repository yields an empty list rather than an error, matching how
|
||||
//! `/api/gitroot` reports "not a repository".
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use maestro_protocol::BranchInfo;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::local_api::LocalApiState;
|
||||
|
||||
/// Query string for `GET /api/branches`. Same `path` convention as
|
||||
/// `/api/dirs` and `/api/gitroot`: resolved against the daemon's home dir.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BranchesQuery {
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub async fn handler(State(s): State<LocalApiState>, Query(q): Query<BranchesQuery>) -> Response {
|
||||
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
|
||||
Json(list(&dir)).into_response()
|
||||
}
|
||||
|
||||
/// List the enclosing repository's local branches. Empty when `dir` is not
|
||||
/// inside a repository.
|
||||
pub fn list(dir: &Path) -> Vec<BranchInfo> {
|
||||
let Some(root) = crate::gitroot::find_root(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let worktrees = worktree_branches(&root);
|
||||
// The first porcelain record is always the main checkout; its branch is
|
||||
// the HEAD answer. Linked worktrees are the occupancy map.
|
||||
let head_branch = worktrees.first().and_then(|(branch, _)| branch.clone());
|
||||
let occupied: HashMap<&str, &Path> = worktrees
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(|(branch, path)| branch.as_deref().map(|b| (b, path.as_path())))
|
||||
.collect();
|
||||
|
||||
branch_names(&root)
|
||||
.into_iter()
|
||||
.map(|name| BranchInfo {
|
||||
is_head: head_branch.as_deref() == Some(name.as_str()),
|
||||
worktree: occupied.get(name.as_str()).copied().map(Path::to_path_buf),
|
||||
name,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_git(root: &Path, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn branch_names(root: &Path) -> Vec<String> {
|
||||
run_git(
|
||||
root,
|
||||
&["for-each-ref", "refs/heads", "--format=%(refname:short)"],
|
||||
)
|
||||
.map(|out| {
|
||||
out.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn worktree_branches(root: &Path) -> Vec<(Option<String>, PathBuf)> {
|
||||
let out = match run_git(root, &["worktree", "list", "--porcelain"]) {
|
||||
Some(out) => out,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
parse_worktrees(&out)
|
||||
}
|
||||
|
||||
/// Parse `git worktree list --porcelain` into `(branch, toplevel)` records,
|
||||
/// branch-less for detached/bare checkouts. Records are separated by blank
|
||||
/// lines; each `worktree` line starts a new one.
|
||||
fn parse_worktrees(out: &str) -> Vec<(Option<String>, PathBuf)> {
|
||||
let mut result: Vec<(Option<String>, PathBuf)> = Vec::new();
|
||||
let mut pending: Option<PathBuf> = None;
|
||||
let mut branch: Option<String> = None;
|
||||
|
||||
for line in out.lines() {
|
||||
if let Some(path) = line.strip_prefix("worktree ") {
|
||||
if let Some(prev) = pending.take() {
|
||||
result.push((branch.take(), prev));
|
||||
}
|
||||
pending = Some(PathBuf::from(path));
|
||||
} else if let Some(b) = line.strip_prefix("branch ") {
|
||||
branch = Some(b.trim_start_matches("refs/heads/").to_owned());
|
||||
}
|
||||
}
|
||||
if let Some(path) = pending {
|
||||
result.push((branch.take(), path));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_main_and_linked_worktrees() {
|
||||
let out = "\
|
||||
worktree /repos/maestro
|
||||
HEAD 0123abcd
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repos/wt-feature
|
||||
HEAD 4567abcd
|
||||
branch refs/heads/spawn-v2
|
||||
|
||||
";
|
||||
assert_eq!(
|
||||
parse_worktrees(out),
|
||||
vec![
|
||||
(Some("main".into()), PathBuf::from("/repos/maestro")),
|
||||
(Some("spawn-v2".into()), PathBuf::from("/repos/wt-feature")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_head_yields_no_branch() {
|
||||
let out = "\
|
||||
worktree /repos/detached
|
||||
HEAD 0123abcd
|
||||
detached
|
||||
|
||||
";
|
||||
assert_eq!(
|
||||
parse_worktrees(out),
|
||||
vec![(None, PathBuf::from("/repos/detached"))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_output_yields_no_records() {
|
||||
assert!(parse_worktrees("").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
//! Home-daemon broker handle (NAP-247).
|
||||
//!
|
||||
//! The HTTP `WS /api/attach/:id` handler uses this to ask the registry to
|
||||
//! resolve a peer-owned session into an [`AttachCandidates`] (a pair-scoped
|
||||
//! JWT + the target daemon's T2 perimeter info). The actual control-link
|
||||
//! send and the reply correlation live here so the handler stays thin.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use maestro_protocol::{AttachCandidates, DaemonOutbound, ReplyTo, RpcResult};
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 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>>>>>,
|
||||
}
|
||||
|
||||
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.
|
||||
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())),
|
||||
};
|
||||
(handle, out_rx)
|
||||
}
|
||||
|
||||
/// Resolve a peer session: ask the registry for an [`AttachCandidates`].
|
||||
pub async fn resolve_attach(&self, session_id: Uuid) -> Result<AttachCandidates, BrokerError> {
|
||||
let reply_to = Uuid::new_v4();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().await.insert(reply_to, tx);
|
||||
|
||||
if self
|
||||
.out_tx
|
||||
.send(DaemonOutbound::ResolveAttach {
|
||||
reply_to,
|
||||
session_id,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
self.pending.lock().await.remove(&reply_to);
|
||||
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(_) => {
|
||||
self.pending.lock().await.remove(&reply_to);
|
||||
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 mut pending = self.pending.lock().await;
|
||||
match pending.remove(&reply_to) {
|
||||
Some(tx) => tx.send(result).is_ok(),
|
||||
None => {
|
||||
tracing::debug!(?reply_to, "no waiter for ResolveAttachResult");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,195 +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(),
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Clap-derive subcommand for the daemon, plus a runner that wires everything
|
||||
//! together.
|
||||
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Subcommand;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::session::DEFAULT_LIVENESS_INTERVAL;
|
||||
use crate::sidecar::SidecarStore;
|
||||
use crate::tmux::Tmux;
|
||||
use crate::worktree::WorktreeManager;
|
||||
use crate::{
|
||||
default_config_path, default_state_dir, load_config, local_api, EnvOverrides, SessionManager,
|
||||
};
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum DaemonCmd {
|
||||
/// Start the daemon. Default when no subcommand is given.
|
||||
Run,
|
||||
}
|
||||
|
||||
/// Dispatch a parsed daemon subcommand. `config_override` is the unified
|
||||
/// `--config` flag from the top-level CLI.
|
||||
pub async fn dispatch(
|
||||
config_override: Option<PathBuf>,
|
||||
cmd: Option<DaemonCmd>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
match cmd.unwrap_or(DaemonCmd::Run) {
|
||||
DaemonCmd::Run => run(config_override).await,
|
||||
}
|
||||
}
|
||||
|
||||
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 config = load_config(&config_path, EnvOverrides::from_env())?;
|
||||
tracing::info!(
|
||||
hostname = %config.hostname,
|
||||
bind = %config.bind_addr,
|
||||
"loaded config"
|
||||
);
|
||||
|
||||
let _ = default_state_dir();
|
||||
std::fs::create_dir_all(config.sidecar_dir())?;
|
||||
std::fs::create_dir_all(config.fifo_dir())?;
|
||||
// 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 worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
|
||||
let tmux = match config.tmux_tmpdir.clone() {
|
||||
Some(p) => Tmux::new().with_tmpdir(p),
|
||||
None => Tmux::new(),
|
||||
};
|
||||
let retained = std::sync::Arc::new(crate::retained_store::RetainedStore::new(
|
||||
config.retained_worktree_dir(),
|
||||
));
|
||||
let manager = SessionManager::new_with_replay(
|
||||
std::sync::Arc::new(tmux),
|
||||
sidecar,
|
||||
worktree_mgr,
|
||||
config.fifo_dir(),
|
||||
config.pty_replay_bytes,
|
||||
)
|
||||
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
|
||||
config.spawn_history_path(),
|
||||
config.spawn_history_days,
|
||||
))
|
||||
// One store instance shared with the API handlers: forced kills (#81)
|
||||
// record here, `GET /api/worktrees` (#80) reads back from here.
|
||||
.with_retained_store((*retained).clone());
|
||||
|
||||
let recovered = manager.recover().await?;
|
||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
|
||||
|
||||
let projects = std::sync::Arc::new(crate::projects::ProjectScanner::new(
|
||||
config.project_roots.clone(),
|
||||
config.project_scan_depth,
|
||||
PathBuf::from(&config.home_dir),
|
||||
));
|
||||
projects.start_background_refresh();
|
||||
|
||||
let router = local_api::router(local_api::state_for(
|
||||
manager,
|
||||
config.hostname.clone(),
|
||||
config.home_dir.clone(),
|
||||
config.spawn_presets.clone(),
|
||||
projects,
|
||||
retained,
|
||||
));
|
||||
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
let bound = listener.local_addr()?;
|
||||
tracing::info!(addr = %bound, "API listening");
|
||||
|
||||
let shutdown = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("ctrl-c received — shutting down");
|
||||
};
|
||||
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+242
-278
@@ -1,44 +1,40 @@
|
||||
//! Daemon view over the unified Maestro config.
|
||||
//!
|
||||
//! Reads `[shared]` (registry_url, token) and `[daemon]` (daemon_id,
|
||||
//! hostname, daemon_url, bind_addr, state_dir, worktree_root) from
|
||||
//! `~/.config/maestro/config.toml`.
|
||||
//! Reads `[daemon]` (hostname, bind_addr, state_dir, worktree_root, …) and
|
||||
//! `[spawn]` from `~/.config/maestro/config.toml`.
|
||||
//!
|
||||
//! Precedence per field: **env > file > default**. The daemon refuses
|
||||
//! to start if `registry_url`, `daemon_url`, or `token` are absent
|
||||
//! from both env and file.
|
||||
//! Precedence per field: **env > file > default**. Every field has a default,
|
||||
//! so a missing config file is fine — the daemon starts with defaults.
|
||||
//!
|
||||
//! Default config path: `~/.config/maestro/config.toml` (override via
|
||||
//! `$MAESTRO_CONFIG`). If the file is missing entirely the daemon
|
||||
//! errors with a pointer at `maestrod configure` — legacy
|
||||
//! `~/.config/maestrod/config.toml` is no longer read.
|
||||
//! `$MAESTRO_CONFIG`).
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use maestro_config::{derive_daemon_ws_url, MaestroConfigFile};
|
||||
use maestro_config::MaestroConfigFile;
|
||||
|
||||
pub use maestro_config::{
|
||||
default_config_path, resolve_config_path, DaemonSection, SharedSection, CONFIG_PATH_ENV,
|
||||
};
|
||||
pub use maestro_config::default_config_path;
|
||||
|
||||
/// Default bind address when neither env nor file specify one.
|
||||
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
|
||||
|
||||
/// 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";
|
||||
/// Default recency cap (days) for the durable spawn history (#24) when
|
||||
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
|
||||
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
|
||||
|
||||
/// Default scan roots for project discovery (#54) when `[spawn].project_roots`
|
||||
/// is unset: the whole home directory, with the scanner's own ignore list
|
||||
/// keeping it cheap.
|
||||
pub const DEFAULT_PROJECT_ROOTS: [&str; 1] = ["~"];
|
||||
/// Default directory depth for project discovery (#54) when
|
||||
/// `[spawn].project_scan_depth` is unset.
|
||||
pub const DEFAULT_PROJECT_SCAN_DEPTH: u32 = 3;
|
||||
|
||||
/// Env var that overrides `[daemon].bind_addr`.
|
||||
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
|
||||
/// Env var that overrides `[daemon].perimeter_id`.
|
||||
pub const ENV_DAEMON_PERIMETER_ID: &str = "MAESTRO_DAEMON_PERIMETER_ID";
|
||||
/// Env var that overrides `[daemon].private_addr`.
|
||||
pub const ENV_DAEMON_PRIVATE_ADDR: &str = "MAESTRO_DAEMON_PRIVATE_ADDR";
|
||||
/// Env var that overrides `[daemon].tmux_tmpdir`.
|
||||
pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR";
|
||||
|
||||
fn default_bind_addr() -> SocketAddr {
|
||||
DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr")
|
||||
@@ -50,46 +46,48 @@ pub enum ConfigError {
|
||||
Inner(#[from] maestro_config::ConfigError),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("missing required config field `{field}` — set it in {path:?} or via env var `{env}`")]
|
||||
Missing {
|
||||
field: &'static str,
|
||||
env: &'static str,
|
||||
path: PathBuf,
|
||||
},
|
||||
#[error("invalid {env} ({input:?}): {source}")]
|
||||
InvalidBindAddr {
|
||||
env: &'static str,
|
||||
input: String,
|
||||
source: std::net::AddrParseError,
|
||||
},
|
||||
#[error("invalid [spawn].presets: {0}")]
|
||||
SpawnPresets(#[from] maestro_config::SpawnConfigError),
|
||||
}
|
||||
|
||||
/// Resolved, validated daemon configuration ready to drive the binary.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DaemonConfig {
|
||||
pub daemon_id: String,
|
||||
/// Hostname advertised to clients, for display only.
|
||||
pub hostname: String,
|
||||
/// Registry WS URL the daemon dials. Derived from `[shared].registry_url`
|
||||
/// via [`maestro_config::derive_daemon_ws_url`].
|
||||
pub registry_url: String,
|
||||
/// Home directory of the OS user the daemon runs as, advertised to clients
|
||||
/// so they can prefill a spawn form's working directory. Empty when `$HOME`
|
||||
/// can't be resolved (NAP-328).
|
||||
pub home_dir: String,
|
||||
pub bind_addr: SocketAddr,
|
||||
pub daemon_url: String,
|
||||
pub token: String,
|
||||
pub state_dir: PathBuf,
|
||||
pub worktree_root: PathBuf,
|
||||
/// Operator-set perimeter identifier. Advertised to the registry in
|
||||
/// heartbeats so peers can dial this daemon over T2 when they share it.
|
||||
pub perimeter_id: Option<String>,
|
||||
/// Daemon's reachable address on the declared perimeter. Only meaningful
|
||||
/// when `perimeter_id` is set.
|
||||
pub private_addr: Option<String>,
|
||||
/// daemon-ids permitted to broker-attach to this daemon's sessions.
|
||||
/// Default empty (deny all). Local attach bypasses. (NAP-247.)
|
||||
pub allow_inbound: Vec<String>,
|
||||
/// `[daemon].worktree_root` override (#56). `None` — the default — places
|
||||
/// worktrees inside their base repo at `<repo>/worktrees/<branch>`;
|
||||
/// operators who want them elsewhere set this.
|
||||
pub worktree_root: Option<PathBuf>,
|
||||
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
|
||||
/// receives this much of the prior PTY output before the live stream.
|
||||
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
|
||||
pub pty_replay_bytes: usize,
|
||||
/// `TMUX_TMPDIR` exported for every `tmux` invocation (NAP-234).
|
||||
/// `None` lets tmux pick its own default (`/tmp/tmux-$UID`); `Some(p)`
|
||||
/// forces sockets under `p` so bare `tmux ls` in a matching user shell
|
||||
/// sees maestro sessions.
|
||||
pub tmux_tmpdir: Option<PathBuf>,
|
||||
/// Recency cap (days) for the durable spawn history that backs the spawn
|
||||
/// form's "recent dirs" picker (#24). `0` disables history. Default
|
||||
/// [`DEFAULT_SPAWN_HISTORY_DAYS`].
|
||||
pub spawn_history_days: u32,
|
||||
/// Resolved spawn-form command presets (#22/#26): `[spawn].presets` (or the
|
||||
/// built-ins) with the implicit `custom` tile appended. Advertised to
|
||||
/// clients in `GET /api/info`.
|
||||
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
|
||||
/// Project-discovery scan roots (#54): `[spawn].project_roots` (default
|
||||
/// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home.
|
||||
pub project_roots: Vec<PathBuf>,
|
||||
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
|
||||
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
|
||||
pub project_scan_depth: u32,
|
||||
}
|
||||
|
||||
impl DaemonConfig {
|
||||
@@ -100,94 +98,54 @@ impl DaemonConfig {
|
||||
pub fn fifo_dir(&self) -> PathBuf {
|
||||
self.state_dir.join("fifos")
|
||||
}
|
||||
|
||||
/// JSONL spawn-history log path (#24): `<state_dir>/spawn-history.jsonl`.
|
||||
pub fn spawn_history_path(&self) -> PathBuf {
|
||||
self.state_dir.join("spawn-history.jsonl")
|
||||
}
|
||||
|
||||
/// Retained-worktree store directory (#78): `<state_dir>/retained-worktrees/`.
|
||||
pub fn retained_worktree_dir(&self) -> PathBuf {
|
||||
self.state_dir.join("retained-worktrees")
|
||||
}
|
||||
}
|
||||
|
||||
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
||||
/// `Option`s; production reads from real env vars in `main`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EnvOverrides {
|
||||
pub daemon_id: Option<String>,
|
||||
pub registry_url: Option<String>,
|
||||
pub bind_addr: Option<String>,
|
||||
pub daemon_url: Option<String>,
|
||||
pub token: Option<String>,
|
||||
pub perimeter_id: Option<String>,
|
||||
pub private_addr: Option<String>,
|
||||
pub tmux_tmpdir: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvOverrides {
|
||||
/// Read every supported env var from the process environment.
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
daemon_id: std::env::var(ENV_DAEMON_ID).ok(),
|
||||
registry_url: std::env::var(ENV_REGISTRY_URL).ok(),
|
||||
bind_addr: std::env::var(ENV_DAEMON_BIND_ADDR).ok(),
|
||||
daemon_url: std::env::var(ENV_DAEMON_URL).ok(),
|
||||
token: std::env::var(ENV_TOKEN).ok(),
|
||||
perimeter_id: std::env::var(ENV_DAEMON_PERIMETER_ID).ok(),
|
||||
private_addr: std::env::var(ENV_DAEMON_PRIVATE_ADDR).ok(),
|
||||
tmux_tmpdir: std::env::var(ENV_DAEMON_TMUX_TMPDIR).ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the runtime configuration. Applies env overrides on top of the
|
||||
/// unified file, fills defaults, validates the three required fields, and
|
||||
/// derives the daemon's WS endpoint from the shared registry URL.
|
||||
///
|
||||
/// Returns [`maestro_config::ConfigError::MissingFile`] (wrapped) when the
|
||||
/// file is absent and not all required fields come from env vars — this is
|
||||
/// the hard-cutover error that points the operator at `maestrod configure`.
|
||||
/// Resolve the runtime configuration: env overrides on top of the unified
|
||||
/// file, then defaults. A missing file is not an error.
|
||||
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, ConfigError> {
|
||||
let file = if path.exists() {
|
||||
MaestroConfigFile::load(path)?
|
||||
} else if env_has_all_required(&env) {
|
||||
// Container / systemd path: every required field comes from env,
|
||||
// so the on-disk file is genuinely optional.
|
||||
MaestroConfigFile::default()
|
||||
} else {
|
||||
return Err(maestro_config::ConfigError::MissingFile {
|
||||
path: path.to_path_buf(),
|
||||
}
|
||||
.into());
|
||||
};
|
||||
let file = MaestroConfigFile::load(path)?;
|
||||
|
||||
let shared = file.shared;
|
||||
let daemon = file.daemon;
|
||||
// Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id,
|
||||
// empty command) refuses startup with a clear error, like other config
|
||||
// validation (#22/#26).
|
||||
let spawn_presets = file.spawn.resolve()?;
|
||||
|
||||
let hostname = daemon.hostname.unwrap_or_else(system_hostname);
|
||||
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 home_dir = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let bind_addr = match env.bind_addr {
|
||||
Some(raw) => raw.parse().map_err(|source| ConfigError::InvalidBindAddr {
|
||||
env: ENV_DAEMON_BIND_ADDR,
|
||||
input: raw,
|
||||
source,
|
||||
})?,
|
||||
Some(raw) => maestro_config::parse_bind_addr(&raw, ENV_DAEMON_BIND_ADDR)?,
|
||||
None => daemon.bind_addr.unwrap_or_else(default_bind_addr),
|
||||
};
|
||||
|
||||
@@ -195,42 +153,68 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
Some(p) => p,
|
||||
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 allow_inbound = daemon.policy.allow_inbound;
|
||||
let pty_replay_bytes = daemon
|
||||
.pty_replay_bytes
|
||||
.unwrap_or(crate::pty::DEFAULT_REPLAY_BYTES);
|
||||
let tmux_tmpdir = env
|
||||
.tmux_tmpdir
|
||||
.map(PathBuf::from)
|
||||
.or(daemon.tmux_tmpdir)
|
||||
.or_else(dirs::runtime_dir);
|
||||
let spawn_history_days = daemon
|
||||
.spawn_history_days
|
||||
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
|
||||
|
||||
// `~` in scan roots means the daemon's own home; an unresolvable home
|
||||
// leaves the tilde as-is and the scanner treats the root as missing.
|
||||
let project_roots: Vec<PathBuf> = file
|
||||
.spawn
|
||||
.project_roots
|
||||
.unwrap_or_else(|| {
|
||||
DEFAULT_PROJECT_ROOTS
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
})
|
||||
.iter()
|
||||
.map(|root| expand_tilde(root, &home_dir))
|
||||
.collect();
|
||||
let project_scan_depth = file
|
||||
.spawn
|
||||
.project_scan_depth
|
||||
.unwrap_or(DEFAULT_PROJECT_SCAN_DEPTH);
|
||||
|
||||
Ok(DaemonConfig {
|
||||
daemon_id,
|
||||
hostname,
|
||||
registry_url,
|
||||
home_dir,
|
||||
bind_addr,
|
||||
daemon_url,
|
||||
token,
|
||||
state_dir,
|
||||
worktree_root,
|
||||
perimeter_id,
|
||||
private_addr,
|
||||
allow_inbound,
|
||||
worktree_root: daemon.worktree_root,
|
||||
pty_replay_bytes,
|
||||
tmux_tmpdir,
|
||||
spawn_history_days,
|
||||
spawn_presets,
|
||||
project_roots,
|
||||
project_scan_depth,
|
||||
})
|
||||
}
|
||||
|
||||
fn env_has_all_required(env: &EnvOverrides) -> bool {
|
||||
env.registry_url.is_some() && env.daemon_url.is_some() && env.token.is_some()
|
||||
/// Expand a leading `~` (or `~/…`) against `home`. Other paths pass through.
|
||||
fn expand_tilde(path: &str, home: &str) -> PathBuf {
|
||||
match path.strip_prefix("~") {
|
||||
Some(_) if home.is_empty() => PathBuf::from(path),
|
||||
Some(rest) => {
|
||||
let mut p = PathBuf::from(home);
|
||||
p.push(rest.trim_start_matches('/'));
|
||||
p
|
||||
}
|
||||
None => PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
|
||||
let base = dirs::state_dir()
|
||||
.or_else(dirs::data_local_dir)
|
||||
.ok_or(maestro_config::ConfigError::NoConfigDir)?;
|
||||
Ok(base.join("maestrod"))
|
||||
Ok(maestro_config::default_state_dir("maestro")?)
|
||||
}
|
||||
|
||||
/// Read the kernel hostname via `gethostname(2)`. Falls back to `"unknown"`
|
||||
@@ -249,60 +233,27 @@ pub fn system_hostname() -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn minimal_file() -> &'static str {
|
||||
r#"
|
||||
[shared]
|
||||
registry_url = "https://r.example"
|
||||
token = "abc"
|
||||
|
||||
[daemon]
|
||||
daemon_url = "ws://localhost:7070"
|
||||
"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_file_defaults_the_rest() {
|
||||
fn empty_file_defaults_everything() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, minimal_file()).unwrap();
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.registry_url, "wss://r.example/daemon");
|
||||
assert_eq!(cfg.daemon_url, "ws://localhost:7070");
|
||||
assert_eq!(cfg.token, "abc");
|
||||
assert_eq!(cfg.bind_addr, default_bind_addr());
|
||||
assert!(cfg.state_dir.ends_with("maestrod"));
|
||||
assert!(cfg.worktree_root.ends_with("worktrees"));
|
||||
assert_eq!(cfg.daemon_id, cfg.hostname);
|
||||
assert!(cfg.state_dir.ends_with("maestro"));
|
||||
assert!(cfg.worktree_root.is_none());
|
||||
assert!(!cfg.hostname.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_with_full_env_works() {
|
||||
// systemd / container scenario: no config file, everything in env.
|
||||
fn missing_file_is_not_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("does-not-exist.toml");
|
||||
let env = EnvOverrides {
|
||||
registry_url: Some("http://r.example".into()),
|
||||
daemon_url: Some("ws://x:7070".into()),
|
||||
token: Some("t".into()),
|
||||
..EnvOverrides::default()
|
||||
};
|
||||
let cfg = load_config(&path, env).unwrap();
|
||||
assert_eq!(cfg.registry_url, "ws://r.example/daemon");
|
||||
assert_eq!(cfg.token, "t");
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.bind_addr, default_bind_addr());
|
||||
assert!(!path.exists(), "load_config must not create the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_without_full_env_errors_with_hard_cutover_message() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("does-not-exist.toml");
|
||||
let err = load_config(&path, EnvOverrides::default()).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("maestrod configure"), "msg: {msg}");
|
||||
assert!(msg.contains("Legacy"), "msg: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_overrides_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -310,75 +261,33 @@ daemon_url = "ws://localhost:7070"
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[shared]
|
||||
registry_url = "http://from-file"
|
||||
token = "from-file"
|
||||
|
||||
[daemon]
|
||||
daemon_id = "from-file"
|
||||
daemon_url = "ws://from-file:1"
|
||||
bind_addr = "127.0.0.1:1"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let env = EnvOverrides {
|
||||
daemon_id: Some("from-env".into()),
|
||||
registry_url: Some("https://from-env".into()),
|
||||
bind_addr: Some("0.0.0.0:9999".into()),
|
||||
daemon_url: Some("ws://from-env:1".into()),
|
||||
token: Some("from-env".into()),
|
||||
..EnvOverrides::default()
|
||||
};
|
||||
let cfg = load_config(&path, env).unwrap();
|
||||
assert_eq!(cfg.daemon_id, "from-env");
|
||||
assert_eq!(cfg.registry_url, "wss://from-env/daemon");
|
||||
assert_eq!(cfg.daemon_url, "ws://from-env:1");
|
||||
assert_eq!(cfg.token, "from-env");
|
||||
assert_eq!(cfg.bind_addr, "0.0.0.0:9999".parse().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_reports_path_and_env_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[shared]
|
||||
registry_url = "http://r"
|
||||
|
||||
[daemon]
|
||||
daemon_url = "ws://x"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_config(&path, EnvOverrides::default()).unwrap_err();
|
||||
match err {
|
||||
ConfigError::Missing {
|
||||
field,
|
||||
env,
|
||||
path: p,
|
||||
} => {
|
||||
assert_eq!(field, "shared.token");
|
||||
assert_eq!(env, ENV_TOKEN);
|
||||
assert_eq!(p, path);
|
||||
}
|
||||
other => panic!("expected Missing(shared.token), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_env_bind_addr_returns_diagnostic() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, minimal_file()).unwrap();
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let env = EnvOverrides {
|
||||
bind_addr: Some("not-an-addr".into()),
|
||||
..EnvOverrides::default()
|
||||
};
|
||||
let err = load_config(&path, env).unwrap_err();
|
||||
match err {
|
||||
ConfigError::InvalidBindAddr { input, env, .. } => {
|
||||
ConfigError::Inner(maestro_config::ConfigError::InvalidBindAddr {
|
||||
input, env, ..
|
||||
}) => {
|
||||
assert_eq!(input, "not-an-addr");
|
||||
assert_eq!(env, ENV_DAEMON_BIND_ADDR);
|
||||
}
|
||||
@@ -387,89 +296,91 @@ daemon_url = "ws://x"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_id_defaults_to_hostname() {
|
||||
fn hostname_from_file_wins_over_gethostname() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[shared]
|
||||
registry_url = "http://r"
|
||||
token = "t"
|
||||
|
||||
[daemon]
|
||||
hostname = "vm-42"
|
||||
daemon_url = "ws://x"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.daemon_id, "vm-42");
|
||||
assert_eq!(cfg.hostname, "vm-42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_url_must_be_http_or_https() {
|
||||
fn spawn_history_days_defaults_and_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.spawn_history_days, DEFAULT_SPAWN_HISTORY_DAYS);
|
||||
assert!(cfg.spawn_history_path().ends_with("spawn-history.jsonl"));
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[daemon]
|
||||
spawn_history_days = 0
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.spawn_history_days, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_scan_defaults_to_home_root_at_depth_3() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.project_scan_depth, DEFAULT_PROJECT_SCAN_DEPTH);
|
||||
assert_eq!(
|
||||
cfg.project_roots,
|
||||
vec![PathBuf::from(cfg.home_dir.clone())],
|
||||
"bare ~ must expand to the daemon's home"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_scan_roots_expand_tilde_and_pass_through_absolutes() {
|
||||
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"
|
||||
[spawn]
|
||||
project_roots = ["~/code", "/srv/work"]
|
||||
project_scan_depth = 5
|
||||
"#,
|
||||
)
|
||||
.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"));
|
||||
assert_eq!(cfg.project_scan_depth, 5);
|
||||
assert_eq!(
|
||||
cfg.project_roots,
|
||||
vec![
|
||||
PathBuf::from(cfg.home_dir.clone()).join("code"),
|
||||
PathBuf::from("/srv/work"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
fn tilde_expansion_handles_bare_and_nested_forms() {
|
||||
assert_eq!(expand_tilde("~", "/home/u"), PathBuf::from("/home/u"));
|
||||
assert_eq!(
|
||||
expand_tilde("~/a/b", "/home/u"),
|
||||
PathBuf::from("/home/u/a/b")
|
||||
);
|
||||
assert_eq!(expand_tilde("/abs", "/home/u"), PathBuf::from("/abs"));
|
||||
// Unresolvable home leaves the tilde as-is rather than mangling it.
|
||||
assert_eq!(expand_tilde("~", ""), PathBuf::from("~"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -477,4 +388,57 @@ private_addr = "10.0.0.7:7070"
|
||||
let h = system_hostname();
|
||||
assert!(!h.is_empty(), "gethostname returned empty string");
|
||||
}
|
||||
|
||||
// NAP-234: tmux_tmpdir precedence is env > file > dirs::runtime_dir().
|
||||
// Tested without mutating the process env (which races other tests) by
|
||||
// feeding `EnvOverrides` directly.
|
||||
|
||||
#[test]
|
||||
fn tmux_tmpdir_env_beats_file_and_runtime_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[daemon]
|
||||
tmux_tmpdir = "/from-file"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let env = EnvOverrides {
|
||||
tmux_tmpdir: Some("/from-env".into()),
|
||||
..EnvOverrides::default()
|
||||
};
|
||||
let cfg = load_config(&path, env).unwrap();
|
||||
assert_eq!(cfg.tmux_tmpdir.as_deref(), Some(Path::new("/from-env")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_tmpdir_file_beats_runtime_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[daemon]
|
||||
tmux_tmpdir = "/from-file"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert_eq!(cfg.tmux_tmpdir.as_deref(), Some(Path::new("/from-file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_tmpdir_defaults_to_xdg_runtime_dir_when_unset() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
// dirs::runtime_dir() is `Some(_)` on any Linux host with
|
||||
// $XDG_RUNTIME_DIR set (CI included). When it's `None`, the daemon
|
||||
// falls through to None and tmux uses /tmp; either is acceptable —
|
||||
// but on this test runner the env var is always present.
|
||||
assert_eq!(cfg.tmux_tmpdir, dirs::runtime_dir());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,297 +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,
|
||||
// Default deny: empty allow_inbound means no peer can attach until the
|
||||
// operator opts in. Set in config.toml under [daemon.policy].
|
||||
policy: Default::default(),
|
||||
// None ⇒ daemon falls back to pty::DEFAULT_REPLAY_BYTES. Override in
|
||||
// config.toml as `[daemon] pty_replay_bytes = ...` (NAP-229).
|
||||
pty_replay_bytes: 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Directory listing for the spawn-form working-directory autocomplete (NAP-329).
|
||||
//!
|
||||
//! A daemon answers `ListDir { path }` by listing the **immediate
|
||||
//! subdirectories** of one directory on its own host. The web client lists one
|
||||
//! directory at a time and filters the trailing path segment locally, so this
|
||||
//! never walks recursively and never returns files.
|
||||
//!
|
||||
//! Path expansion is done against the daemon's own `$HOME` so a client can send
|
||||
//! an abbreviated path (empty, `~`, or relative) and get a sensible result:
|
||||
//!
|
||||
//! - empty → home dir
|
||||
//! - `~` / `~/sub` → home / home + `sub`
|
||||
//! - relative `a/b` → home + `a/b`
|
||||
//! - absolute `/a/b` → as-is
|
||||
//!
|
||||
//! The pure [`list_dir`] is unit-tested in isolation; the HTTP handler
|
||||
//! just wraps its `Result` in an `RpcResult` for the wire.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use maestro_protocol::{DirListing, ErrorInfo};
|
||||
|
||||
/// Maximum number of entries returned in a single listing. A directory with
|
||||
/// more subdirectories than this is reported `truncated`; the client narrows
|
||||
/// its query by typing more of the name. Bounds the wire payload regardless of
|
||||
/// how large a directory is.
|
||||
pub const MAX_ENTRIES: usize = 1000;
|
||||
|
||||
/// List the immediate subdirectories of `requested`, expanding it against
|
||||
/// `home_dir` first. Returns directory **names** only (not full paths), sorted
|
||||
/// case-insensitively. Hidden directories (dotfiles) are excluded.
|
||||
pub fn list_dir(home_dir: &str, requested: &str) -> Result<DirListing, ErrorInfo> {
|
||||
let dir = resolve(home_dir, requested);
|
||||
|
||||
let read = std::fs::read_dir(&dir).map_err(|e| io_error(&dir, &e))?;
|
||||
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
for entry in read.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
// Skip hidden directories — the spawn form rarely targets dotfile dirs
|
||||
// and they only clutter the completion list.
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if is_dir(&entry) {
|
||||
entries.push(name);
|
||||
}
|
||||
}
|
||||
// Case-insensitive sort so `Projects` and `projects` interleave the way a
|
||||
// user scanning the list expects.
|
||||
entries.sort_by_key(|s| s.to_lowercase());
|
||||
|
||||
let truncated = entries.len() > MAX_ENTRIES;
|
||||
entries.truncate(MAX_ENTRIES);
|
||||
|
||||
Ok(DirListing {
|
||||
path: dir,
|
||||
entries,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand an abbreviated client path against the daemon's home dir.
|
||||
///
|
||||
/// Shared with the gitroot probe (#26) so a `~`/relative/empty path the client
|
||||
/// sends is resolved against the daemon's `$HOME` the same way before walking
|
||||
/// up for the enclosing repo.
|
||||
pub fn resolve(home_dir: &str, requested: &str) -> PathBuf {
|
||||
let home = PathBuf::from(home_dir);
|
||||
let trimmed = requested.trim();
|
||||
if trimmed.is_empty() {
|
||||
return home;
|
||||
}
|
||||
if trimmed == "~" {
|
||||
return home;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("~/") {
|
||||
return home.join(rest);
|
||||
}
|
||||
let p = PathBuf::from(trimmed);
|
||||
if p.is_absolute() {
|
||||
p
|
||||
} else {
|
||||
home.join(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `DirEntry` denotes a directory, following symlinks so that a
|
||||
/// symlinked project dir still completes (shells do the same).
|
||||
fn is_dir(entry: &std::fs::DirEntry) -> bool {
|
||||
match entry.file_type() {
|
||||
Ok(ft) if ft.is_dir() => true,
|
||||
Ok(ft) if ft.is_symlink() => std::fs::metadata(entry.path())
|
||||
.map(|m| m.is_dir())
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `read_dir` failure to a stable [`ErrorInfo`] code the client can react
|
||||
/// to. `NotADirectory` isn't a stable `ErrorKind` across platforms, so we probe
|
||||
/// with a `metadata` call to distinguish "file, not dir" from "doesn't exist".
|
||||
fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
|
||||
use std::io::ErrorKind;
|
||||
let (code, message) = match e.kind() {
|
||||
ErrorKind::NotFound => ("not_found", "no such directory"),
|
||||
ErrorKind::PermissionDenied => ("permission_denied", "permission denied"),
|
||||
_ if dir.is_file() => ("not_a_directory", "path is a file, not a directory"),
|
||||
_ => ("io_error", "could not read directory"),
|
||||
};
|
||||
ErrorInfo {
|
||||
code: code.into(),
|
||||
message: format!("{message}: {}", dir.display()),
|
||||
unsaved_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// Build a temp home with a few subdirs (one hidden), a file, and a symlink
|
||||
/// to a dir. Returns the temp root path (its `Drop` cleans up).
|
||||
fn fixture() -> tempfile::TempDir {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path();
|
||||
fs::create_dir(home.join("projects")).unwrap();
|
||||
fs::create_dir(home.join("Downloads")).unwrap();
|
||||
fs::create_dir(home.join(".config")).unwrap();
|
||||
fs::write(home.join("notes.txt"), b"x").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(home.join("projects"), home.join("link-to-projects")).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_visible_dirs_only_sorted_case_insensitively() {
|
||||
let tmp = fixture();
|
||||
let home = tmp.path().to_string_lossy();
|
||||
let listing = list_dir(&home, "").unwrap();
|
||||
// Files and hidden dirs are excluded; visible dirs (incl. a symlinked
|
||||
// dir) are present.
|
||||
assert!(!listing.entries.contains(&".config".to_string()));
|
||||
assert!(listing.entries.contains(&"projects".to_string()));
|
||||
assert!(listing.entries.contains(&"Downloads".to_string()));
|
||||
assert!(!listing.entries.contains(&"notes.txt".to_string()));
|
||||
#[cfg(unix)]
|
||||
assert!(listing.entries.contains(&"link-to-projects".to_string()));
|
||||
// Case-insensitive order: .config, Downloads, link-to-projects, projects
|
||||
let lower: Vec<String> = listing.entries.iter().map(|s| s.to_lowercase()).collect();
|
||||
let mut sorted = lower.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(lower, sorted);
|
||||
assert!(!listing.truncated);
|
||||
assert_eq!(listing.path, tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_tilde_resolve_to_home() {
|
||||
let tmp = fixture();
|
||||
let home = tmp.path().to_string_lossy();
|
||||
assert_eq!(list_dir(&home, "").unwrap().path, tmp.path());
|
||||
assert_eq!(list_dir(&home, "~").unwrap().path, tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_and_tilde_slash_join_onto_home() {
|
||||
let tmp = fixture();
|
||||
let home = tmp.path().to_string_lossy();
|
||||
let rel = list_dir(&home, "projects").unwrap();
|
||||
assert_eq!(rel.path, tmp.path().join("projects"));
|
||||
let tilde = list_dir(&home, "~/projects").unwrap();
|
||||
assert_eq!(tilde.path, tmp.path().join("projects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_is_listed_as_is() {
|
||||
let tmp = fixture();
|
||||
let abs = tmp.path().join("projects");
|
||||
// home is irrelevant for an absolute path.
|
||||
let listing = list_dir("/nonexistent-home", &abs.to_string_lossy()).unwrap();
|
||||
assert_eq!(listing.path, abs);
|
||||
assert!(listing.entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_dir_is_not_found() {
|
||||
let tmp = fixture();
|
||||
let err = list_dir(&tmp.path().to_string_lossy(), "does-not-exist").unwrap_err();
|
||||
assert_eq!(err.code, "not_found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_target_is_not_a_directory() {
|
||||
let tmp = fixture();
|
||||
let err = list_dir(&tmp.path().to_string_lossy(), "notes.txt").unwrap_err();
|
||||
assert_eq!(err.code, "not_a_directory");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Git-toplevel probe for the spawn-form worktree section (#25).
|
||||
//!
|
||||
//! When the user picks a working directory, the spawn form wants to offer a
|
||||
//! "git worktree" toggle that needs only a branch name — the `base_repo` is
|
||||
//! *inferred* from the chosen dir rather than typed. This module answers that
|
||||
//! one question: "what git repository, if any, encloses this path?"
|
||||
//!
|
||||
//! [`find_root`] walks up from a path to the nearest ancestor containing a
|
||||
//! `.git` entry and returns that ancestor (the repo toplevel). A regular `.git`
|
||||
//! directory and a `.git` *file* (the linked-worktree / submodule form) both
|
||||
//! count, so a dir that is itself inside a checked-out worktree still resolves
|
||||
//! to its worktree toplevel.
|
||||
//!
|
||||
//! It's a pure filesystem walk with no `git` subprocess — same shape as
|
||||
//! [`crate::dirlist`]: unit-tested in isolation, and the HTTP handler /
|
||||
//! HTTP layer (#26) just wraps its `Option` for the wire.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Walk up from `start` to the nearest ancestor that holds a `.git` entry and
|
||||
/// return that ancestor — the enclosing repository's toplevel. Returns `None`
|
||||
/// when no ancestor is a git repo.
|
||||
///
|
||||
/// Both a `.git` directory (ordinary checkout) and a `.git` file (linked
|
||||
/// worktree or submodule, which stores a `gitdir:` pointer) count as a repo
|
||||
/// marker, matching what `git rev-parse --show-toplevel` would resolve. `start`
|
||||
/// itself is checked first, so passing a repo root returns that root.
|
||||
///
|
||||
/// The input is used as-is: callers that accept abbreviated client paths
|
||||
/// should resolve them (e.g. via [`crate::dirlist`]'s home-dir expansion)
|
||||
/// before probing, so an empty or `~` path doesn't walk up from the process
|
||||
/// working directory.
|
||||
pub fn find_root(start: &Path) -> Option<PathBuf> {
|
||||
// `ancestors()` yields `start`, its parent, grandparent, … up to the root,
|
||||
// so a repo toplevel is matched at the first hit (closest enclosing repo).
|
||||
start
|
||||
.ancestors()
|
||||
.find(|dir| is_git_root(dir))
|
||||
.map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// Whether `dir` holds a `.git` entry — directory or file. A bare `exists()`
|
||||
/// check is deliberate: we don't open or parse it, only detect the marker.
|
||||
fn is_git_root(dir: &Path) -> bool {
|
||||
dir.join(".git").exists()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// A temp repo: `<root>/.git/` plus a nested `a/b/c` subtree. Returns the
|
||||
/// temp dir (its `Drop` cleans up) so callers keep it alive for the test.
|
||||
fn repo_fixture() -> tempfile::TempDir {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir(root.join(".git")).unwrap();
|
||||
fs::create_dir_all(root.join("a/b/c")).unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_inside_repo_resolves_to_toplevel() {
|
||||
let tmp = repo_fixture();
|
||||
// The repo root itself resolves to the repo root.
|
||||
assert_eq!(find_root(tmp.path()).as_deref(), Some(tmp.path()));
|
||||
// An immediate subdir resolves to the toplevel, not itself.
|
||||
assert_eq!(
|
||||
find_root(&tmp.path().join("a")).as_deref(),
|
||||
Some(tmp.path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_subdir_resolves_to_toplevel() {
|
||||
let tmp = repo_fixture();
|
||||
let deep = tmp.path().join("a/b/c");
|
||||
assert_eq!(find_root(&deep).as_deref(), Some(tmp.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_repo_dir_is_none() {
|
||||
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
|
||||
// the walk can't accidentally hit a real repo above the test's cwd.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sub = tmp.path().join("plain/dir");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
assert_eq!(find_root(&sub), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_file_worktree_marker_counts_as_root() {
|
||||
// Linked worktrees and submodules store `.git` as a *file* holding a
|
||||
// `gitdir:` pointer rather than a directory. It must still resolve.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::write(root.join(".git"), b"gitdir: /somewhere/.git/worktrees/wt\n").unwrap();
|
||||
let sub = root.join("src");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
assert_eq!(find_root(&sub).as_deref(), Some(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_enclosing_repo_wins() {
|
||||
// An inner repo nested inside an outer repo resolves to the *inner*
|
||||
// toplevel — the walk stops at the first `.git` it meets going up.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let outer = tmp.path();
|
||||
fs::create_dir(outer.join(".git")).unwrap();
|
||||
let inner = outer.join("vendored");
|
||||
fs::create_dir(&inner).unwrap();
|
||||
fs::create_dir(inner.join(".git")).unwrap();
|
||||
let inner_sub = inner.join("src");
|
||||
fs::create_dir(&inner_sub).unwrap();
|
||||
assert_eq!(find_root(&inner_sub).as_deref(), Some(inner.as_path()));
|
||||
}
|
||||
}
|
||||
@@ -1,264 +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 std::collections::HashSet;
|
||||
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use maestro_protocol::{JwtClaims, PairAttachClaims};
|
||||
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 },
|
||||
#[error("home daemon `{sub}` not in this daemon's allow_inbound")]
|
||||
NotAllowed { sub: String },
|
||||
}
|
||||
|
||||
#[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`,
|
||||
/// `aud == self.daemon_id`, and `sub in allow_inbound`. The `allow_inbound`
|
||||
/// list is read at daemon startup; hot reload is NAP-237.
|
||||
#[derive(Clone)]
|
||||
pub struct PairAttachVerifier {
|
||||
decoding: DecodingKey,
|
||||
daemon_id: String,
|
||||
allow_inbound: HashSet<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("allow_inbound", &self.allow_inbound)
|
||||
.field("decoding", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PairAttachVerifier {
|
||||
pub fn new(
|
||||
secret: &str,
|
||||
daemon_id: impl Into<String>,
|
||||
allow_inbound: impl IntoIterator<Item = String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
decoding: DecodingKey::from_secret(secret.as_bytes()),
|
||||
daemon_id: daemon_id.into(),
|
||||
allow_inbound: allow_inbound.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()]);
|
||||
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,
|
||||
});
|
||||
}
|
||||
if !self.allow_inbound.contains(&claims.sub) {
|
||||
return Err(JwtError::NotAllowed { sub: claims.sub });
|
||||
}
|
||||
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".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_allow_listed_home() {
|
||||
let secret = "s";
|
||||
let v = PairAttachVerifier::new(secret, "konishi", ["laptop".into()]);
|
||||
let sid = Uuid::new_v4();
|
||||
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_unlisted_home() {
|
||||
let secret = "s";
|
||||
let v = PairAttachVerifier::new(secret, "konishi", ["other".into()]);
|
||||
let sid = Uuid::new_v4();
|
||||
let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60));
|
||||
let err = v.verify(&token, sid).unwrap_err();
|
||||
assert!(matches!(err, JwtError::NotAllowed { .. }), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_verifier_rejects_empty_allow_inbound() {
|
||||
// Default deny: no peer listed → every cross-daemon attach fails.
|
||||
let secret = "s";
|
||||
let v = PairAttachVerifier::new(secret, "konishi", std::iter::empty::<String>());
|
||||
let sid = Uuid::new_v4();
|
||||
let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60));
|
||||
assert!(matches!(
|
||||
v.verify(&token, sid).unwrap_err(),
|
||||
JwtError::NotAllowed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_verifier_rejects_wrong_audience() {
|
||||
let secret = "s";
|
||||
let v = PairAttachVerifier::new(secret, "konishi", ["laptop".into()]);
|
||||
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_session_id_mismatch() {
|
||||
let secret = "s";
|
||||
let v = PairAttachVerifier::new(secret, "konishi", ["laptop".into()]);
|
||||
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", ["laptop".into()]);
|
||||
let sid = Uuid::new_v4();
|
||||
let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, -120));
|
||||
assert!(v.verify(&token, sid).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,31 @@
|
||||
//! `maestrod` — the per-host Maestro daemon.
|
||||
//! `maestro daemon` — the per-host Maestro daemon.
|
||||
//!
|
||||
//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §Daemon for the
|
||||
//! component's responsibilities. This library exposes the pieces; the binary
|
||||
//! entry point in `src/main.rs` wires them together.
|
||||
//! Wraps tmux, owns every session on the machine, and serves them to clients
|
||||
//! (CLI, TUI, web) over a plain HTTP + WebSocket API on a local bind address.
|
||||
//! No registry, no peers, no auth layer: reaching the bind address is the
|
||||
//! authorization.
|
||||
|
||||
#![deny(missing_debug_implementations)]
|
||||
|
||||
pub mod activity;
|
||||
pub mod attach;
|
||||
pub mod broker;
|
||||
pub mod catalog_mirror;
|
||||
pub mod branches;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod configure;
|
||||
pub mod jwt;
|
||||
pub mod dirlist;
|
||||
pub mod gitroot;
|
||||
pub mod local_api;
|
||||
pub mod local_events;
|
||||
pub mod panes;
|
||||
pub mod proc;
|
||||
pub mod projects;
|
||||
pub mod pty;
|
||||
pub mod registry_client;
|
||||
pub mod retained_store;
|
||||
pub mod session;
|
||||
pub mod sidecar;
|
||||
pub mod spawn_history;
|
||||
pub mod tmux;
|
||||
pub mod worktree;
|
||||
pub mod worktree_probe;
|
||||
|
||||
pub use broker::{BrokerError, BrokerHandle};
|
||||
pub use catalog_mirror::CatalogMirror;
|
||||
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
|
||||
pub use configure::{run as run_configure, ConfigureError, ConfigureInputs};
|
||||
pub use jwt::{issue as issue_jwt, JwtError, JwtVerifier, PairAttachVerifier};
|
||||
pub use local_events::{spawn_session_publisher, LocalEventBus};
|
||||
pub use registry_client::{ClientError, RegistryClient, HEARTBEAT_INTERVAL};
|
||||
pub use session::{SessionError, SessionEvent, SessionManager};
|
||||
pub use session::{SessionError, SessionManager};
|
||||
|
||||
@@ -1,99 +1,159 @@
|
||||
//! Daemon-side HTTP API for local clients (NAP-243).
|
||||
//! Daemon-side HTTP API for clients (NAP-243).
|
||||
//!
|
||||
//! These routes live on the daemon's existing `bind_addr` (default
|
||||
//! `127.0.0.1:7070`) alongside the legacy `/sessions/:id/stream` WS endpoint.
|
||||
//! They're the contract `maestro-web` (NAP-241) and the rewired CLI (NAP-244)
|
||||
//! call against — a same-origin, same-trust surface that doesn't need a
|
||||
//! registry round-trip for catalog reads.
|
||||
//! These routes live on the daemon's `bind_addr` (default `127.0.0.1:7070`).
|
||||
//! They're the contract `maestro-web`, the TUI, and the CLI call against.
|
||||
//!
|
||||
//! ## Trust model (v1)
|
||||
//! ## Trust model
|
||||
//!
|
||||
//! Whoever can reach `bind_addr` can use these routes. No JWT, no cookie. The
|
||||
//! default `127.0.0.1` bind makes this same-trust as tmux (one-user-wide).
|
||||
//! Operators who bind a perimeter address (LAN/WG IP) opt into one-segment-wide
|
||||
//! trust — same model `maestro-web` documents per the connection-orchestration
|
||||
//! spec. Cookie auth can be added later if a real use case demands it.
|
||||
//!
|
||||
//! ## Scope (v1)
|
||||
//!
|
||||
//! The daemon only surfaces its own sessions and itself. Peer-catalog mirroring
|
||||
//! is NAP-246; cross-daemon broker attach is NAP-247. Until those land,
|
||||
//! `POST /api/sessions` rejects requests targeting other daemons and
|
||||
//! `WS /api/attach/:id` only handles sessions this daemon owns.
|
||||
//! Operators who bind a LAN/VPN address opt into one-segment-wide trust — the
|
||||
//! perimeter is theirs to choose, Maestro does not police it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
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 maestro_protocol::{CreateSessionRequest, DaemonInfo, ErrorInfo, Session, PROTOCOL_VERSION};
|
||||
use serde::Serialize;
|
||||
use maestro_config::SpawnPreset;
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session,
|
||||
PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::catalog_mirror::CatalogMirror;
|
||||
use crate::local_events::LocalEventBus;
|
||||
use crate::session::SessionManager;
|
||||
|
||||
/// Daemon identity returned by `GET /api/web/info`. Browsers render this in the
|
||||
/// header ("you're connected to konishi"); the field set mirrors `DaemonInfo`
|
||||
/// plus the running daemon's protocol version.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WebInfo {
|
||||
pub daemon_id: String,
|
||||
pub hostname: String,
|
||||
pub version: String,
|
||||
pub daemon_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalApiState {
|
||||
pub manager: SessionManager,
|
||||
pub hostname: Arc<String>,
|
||||
pub daemon_url: Arc<String>,
|
||||
/// 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,
|
||||
/// Home directory of the OS user this daemon runs as, advertised so clients
|
||||
/// can prefill a spawn form's working directory. Empty if unresolved.
|
||||
pub home_dir: Arc<String>,
|
||||
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
|
||||
pub presets: Arc<Vec<SpawnPreset>>,
|
||||
/// Project discovery (#54) backing `GET /api/projects`.
|
||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||
/// Retained worktrees (#78) backing `GET /api/worktrees` and their
|
||||
/// reclaim endpoint (#80).
|
||||
pub retained: Arc<crate::retained_store::RetainedStore>,
|
||||
}
|
||||
|
||||
pub fn router(state: LocalApiState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/sessions", get(list_sessions).post(create_session))
|
||||
.route("/api/sessions/:id", delete(kill_session))
|
||||
.route("/api/daemons", get(list_daemons))
|
||||
.route("/api/web/info", get(web_info))
|
||||
.route("/api/events", any(events_ws))
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
.route(routes::PROJECTS, get(projects))
|
||||
.route(routes::GITROOT, get(git_root))
|
||||
.route(routes::BRANCHES, get(crate::branches::handler))
|
||||
.route(routes::NAMES, get(names))
|
||||
.route(routes::WORKTREES, get(list_worktrees))
|
||||
.route(routes::patterns::WORKTREE, delete(reclaim_worktree))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::EVENTS, any(events_ws))
|
||||
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn list_sessions(State(s): State<LocalApiState>) -> Json<Vec<Session>> {
|
||||
let mut out = s.manager.snapshot().await;
|
||||
out.extend(s.catalog.sessions().await);
|
||||
Json(out)
|
||||
Json(s.manager.snapshot().await)
|
||||
}
|
||||
|
||||
async fn list_daemons(State(s): State<LocalApiState>) -> Json<Vec<DaemonInfo>> {
|
||||
let self_info = DaemonInfo {
|
||||
daemon_id: s.manager.daemon_id().to_string(),
|
||||
hostname: (*s.hostname).clone(),
|
||||
version: PROTOCOL_VERSION.into(),
|
||||
daemon_url: (*s.daemon_url).clone(),
|
||||
};
|
||||
let mut out = vec![self_info];
|
||||
out.extend(s.catalog.daemons().await);
|
||||
Json(out)
|
||||
/// Query string for `GET /api/dirs`. `path` is the directory to list (the
|
||||
/// parent of what the user is typing); empty/`~`/relative are resolved against
|
||||
/// the daemon's home dir.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DirQuery {
|
||||
#[serde(default)]
|
||||
path: String,
|
||||
}
|
||||
|
||||
async fn web_info(State(s): State<LocalApiState>) -> Json<WebInfo> {
|
||||
Json(WebInfo {
|
||||
daemon_id: s.manager.daemon_id().to_string(),
|
||||
/// List one directory's immediate subdirectories, for the spawn form's
|
||||
/// working-directory autocomplete (NAP-329).
|
||||
async fn list_dir(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) -> Response {
|
||||
match crate::dirlist::list_dir(&s.home_dir, &q.path) {
|
||||
Ok(listing) => Json(listing).into_response(),
|
||||
Err(info) => error_response(dir_error_status(&info.code), &info.code, &info.message),
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP status for a local [`crate::dirlist`] error code.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP status for a [`crate::session::SessionError`] code. Everything used to
|
||||
/// collapse to 502, which reports a client mistake as a daemon failure.
|
||||
fn session_error_status(code: &str) -> StatusCode {
|
||||
match code {
|
||||
"not_found" => StatusCode::NOT_FOUND,
|
||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||
"kill_guarded" => StatusCode::CONFLICT,
|
||||
// The retention record failed before anything was torn down (#81).
|
||||
"retain_failed" => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recent working dirs for the spawn form's recents picker (#26).
|
||||
async fn recent_dirs(State(s): State<LocalApiState>) -> Response {
|
||||
Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response()
|
||||
}
|
||||
|
||||
/// Discovered git repositories and worktrees, newest activity first (#54).
|
||||
/// `last_activity` is the later of the repo's git HEAD mtime and the newest
|
||||
/// spawn-history use of any directory inside it.
|
||||
async fn projects(State(s): State<LocalApiState>) -> Response {
|
||||
let repos = s.projects.repos().await;
|
||||
let history = s.manager.all_recent_dirs();
|
||||
Json(crate::projects::to_projects(&repos, &history)).into_response()
|
||||
}
|
||||
|
||||
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
|
||||
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
|
||||
/// (empty/`~`/relative), like [`list_dir`].
|
||||
async fn git_root(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) -> Response {
|
||||
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
|
||||
Json(GitRoot {
|
||||
root: crate::gitroot::find_root(&dir),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Query string for `GET /api/names`. `count` defaults to 1 and is capped at
|
||||
/// [`maestro_names::MAX_BATCH`] (#53).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NamesQuery {
|
||||
count: Option<usize>,
|
||||
}
|
||||
|
||||
/// Fresh session handles for the spawn form's instant reroll (#53). The web
|
||||
/// client fetches one batch per draft pane and rerolls locally from it.
|
||||
async fn names(Query(q): Query<NamesQuery>) -> Json<NameBatch> {
|
||||
let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH);
|
||||
Json(NameBatch {
|
||||
names: maestro_names::generate_batch(count),
|
||||
})
|
||||
}
|
||||
|
||||
async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
|
||||
Json(DaemonInfo {
|
||||
hostname: (*s.hostname).clone(),
|
||||
version: PROTOCOL_VERSION.into(),
|
||||
daemon_url: (*s.daemon_url).clone(),
|
||||
home_dir: (*s.home_dir).clone(),
|
||||
presets: (*s.presets).clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,26 +161,12 @@ async fn create_session(
|
||||
State(s): State<LocalApiState>,
|
||||
Json(body): Json<CreateSessionRequest>,
|
||||
) -> Response {
|
||||
if body.daemon_id != s.manager.daemon_id() {
|
||||
// Forwarding to peer daemons is NAP-247 (broker mode). Until then,
|
||||
// tell the caller exactly why we refused — the registry's API still
|
||||
// accepts cross-daemon creates the old way, so this isn't a regression.
|
||||
return error_response(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"remote_create_unsupported",
|
||||
&format!(
|
||||
"this daemon ({}) can only create sessions for itself; \
|
||||
cross-daemon broker is NAP-247",
|
||||
s.manager.daemon_id()
|
||||
),
|
||||
);
|
||||
}
|
||||
match s.manager.create(body).await {
|
||||
Ok(session) => (StatusCode::CREATED, Json(session)).into_response(),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
session_error_status(&info.code),
|
||||
info.code.as_str(),
|
||||
info.message.as_str(),
|
||||
)
|
||||
@@ -128,62 +174,206 @@ async fn create_session(
|
||||
}
|
||||
}
|
||||
|
||||
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||
if !s.manager.has(id).await {
|
||||
// The session might exist on a peer daemon, but v1 doesn't broker —
|
||||
// give a clean 404 rather than lying about success.
|
||||
return error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_session",
|
||||
"no such session on this daemon",
|
||||
);
|
||||
/// `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.
|
||||
///
|
||||
/// The full error info is passed through rather than rebuilt from
|
||||
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
|
||||
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
|
||||
///
|
||||
/// With `force=true` (#81) the refusal is overridden and the worktree
|
||||
/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept.
|
||||
/// Without it the historical `204` stands.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KillQuery {
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
async fn kill_session(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(q): Query<KillQuery>,
|
||||
) -> Response {
|
||||
match s.manager.kill(id, q.force).await {
|
||||
Ok(outcome) => {
|
||||
if q.force {
|
||||
(StatusCode::OK, Json(outcome)).into_response()
|
||||
} else {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
}
|
||||
match s.manager.kill(id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
error_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
info.code.as_str(),
|
||||
info.message.as_str(),
|
||||
let status = session_error_status(&info.code);
|
||||
(status, Json(ApiError { error: info })).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
|
||||
/// breakdown recomputed at read time — never stored, so a tree whose work has
|
||||
/// since been committed or pushed says so. Entries whose directory somebody
|
||||
/// removed by hand are pruned on the way through.
|
||||
async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
|
||||
let retained = s.retained.clone();
|
||||
let listed = tokio::task::spawn_blocking(move || {
|
||||
retained.prune_missing()?;
|
||||
retained
|
||||
.load_all()?
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
// A tree git cannot read (permissions, corrupt checkout)
|
||||
// still gets listed; it just reports nothing in the way.
|
||||
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
|
||||
Ok(maestro_protocol::RetainedWorktree {
|
||||
id: entry.id,
|
||||
path: entry.path.clone(),
|
||||
base_repo: entry.base_repo.clone(),
|
||||
branch: entry.branch.clone(),
|
||||
reason: entry.reason,
|
||||
retained_at: entry.retained_at,
|
||||
in_the_way,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, crate::retained_store::RetainedStoreError>>()
|
||||
})
|
||||
.await;
|
||||
match listed {
|
||||
Ok(Ok(worktrees)) => Json(worktrees).into_response(),
|
||||
Ok(Err(e)) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"store_failed",
|
||||
&e.to_string(),
|
||||
),
|
||||
Err(e) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"join_failed",
|
||||
&e.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes
|
||||
/// the directory and drops the store entry, without consulting the guard —
|
||||
/// reclaim is the deliberate destructive act, chosen while looking at what is
|
||||
/// in the way. The branch itself is never deleted; commits on it survive.
|
||||
async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||
let found = {
|
||||
let retained = s.retained.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<
|
||||
Option<crate::retained_store::RetainedWorktreeRecord>,
|
||||
crate::retained_store::RetainedStoreError,
|
||||
> {
|
||||
Ok(retained.load_all()?.into_iter().find(|e| e.id == id))
|
||||
})
|
||||
.await
|
||||
};
|
||||
let entry = match found {
|
||||
Ok(Ok(Some(entry))) => entry,
|
||||
Ok(Ok(None)) => {
|
||||
return error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"not_found",
|
||||
"no such retained worktree",
|
||||
)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
return error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"store_failed",
|
||||
&e.to_string(),
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
return error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"join_failed",
|
||||
&e.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = {
|
||||
let retained = s.retained.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<(), String> {
|
||||
crate::worktree_probe::reclaim(&entry.base_repo, &entry.path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
retained.remove(id).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
};
|
||||
match outcome {
|
||||
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
|
||||
// The directory could not be removed: leave the entry so the
|
||||
// operator still sees the tree and can retry.
|
||||
Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()),
|
||||
Err(e) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"join_failed",
|
||||
&e.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"error": ErrorInfo {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
},
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
(status, Json(ApiError::new(code, message))).into_response()
|
||||
}
|
||||
|
||||
/// 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. The project
|
||||
/// scanner starts with its cache cold; wire
|
||||
/// [`ProjectScanner::start_background_refresh`](crate::projects::ProjectScanner::start_background_refresh)
|
||||
/// on the returned handle's scanner to keep it eventually fresh.
|
||||
pub fn state_for(
|
||||
manager: SessionManager,
|
||||
hostname: impl Into<String>,
|
||||
daemon_url: impl Into<String>,
|
||||
catalog: CatalogMirror,
|
||||
events: LocalEventBus,
|
||||
home_dir: impl Into<String>,
|
||||
presets: Vec<SpawnPreset>,
|
||||
projects: Arc<crate::projects::ProjectScanner>,
|
||||
retained: Arc<crate::retained_store::RetainedStore>,
|
||||
) -> LocalApiState {
|
||||
LocalApiState {
|
||||
manager,
|
||||
hostname: Arc::new(hostname.into()),
|
||||
daemon_url: Arc::new(daemon_url.into()),
|
||||
catalog,
|
||||
events,
|
||||
home_dir: Arc::new(home_dir.into()),
|
||||
presets: Arc::new(presets),
|
||||
projects,
|
||||
retained,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(
|
||||
@@ -201,7 +391,6 @@ async fn pump_events(
|
||||
) -> Result<(), axum::Error> {
|
||||
use axum::extract::ws::Message;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::RegistryEvent;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
@@ -209,19 +398,11 @@ async fn pump_events(
|
||||
// Subscribe BEFORE building the snapshot so deltas applied while we're
|
||||
// marshaling can't slip between the two and disappear. The UI is
|
||||
// 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 {
|
||||
daemon_id: s.manager.daemon_id().to_string(),
|
||||
hostname: (*s.hostname).clone(),
|
||||
version: PROTOCOL_VERSION.into(),
|
||||
daemon_url: (*s.daemon_url).clone(),
|
||||
let snapshot = CatalogEvent::Snapshot {
|
||||
sessions: s.manager.snapshot().await,
|
||||
};
|
||||
let mut daemons = vec![self_info];
|
||||
daemons.extend(s.catalog.daemons().await);
|
||||
let mut sessions = s.manager.snapshot().await;
|
||||
sessions.extend(s.catalog.sessions().await);
|
||||
let snapshot = RegistryEvent::Snapshot { daemons, sessions };
|
||||
let text = serde_json::to_string(&snapshot).expect("snapshot encode");
|
||||
if sink.send(Message::Text(text)).await.is_err() {
|
||||
return Ok(());
|
||||
@@ -232,7 +413,7 @@ async fn pump_events(
|
||||
ev = rx.recv() => {
|
||||
let event = match ev {
|
||||
Ok(e) => e,
|
||||
Err(RecvError::Lagged(n)) => RegistryEvent::Lagged { skipped: n },
|
||||
Err(RecvError::Lagged(n)) => CatalogEvent::Lagged { skipped: n },
|
||||
Err(RecvError::Closed) => break,
|
||||
};
|
||||
let text = serde_json::to_string(&event).expect("event encode");
|
||||
@@ -254,190 +435,3 @@ async fn pump_events(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Broker WS — `WS /api/attach/:session_id`
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Home-daemon broker (NAP-243 + NAP-247):
|
||||
//
|
||||
// 1. Session owned by self → delegate to local PTY subscribe.
|
||||
// 2. Session owned by a peer daemon → ResolveAttach via the registry, dial
|
||||
// the peer over T2, proxy frames.
|
||||
// 3. Unknown session → 404.
|
||||
|
||||
use crate::broker::{BrokerError, BrokerHandle};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrokerState {
|
||||
pub manager: SessionManager,
|
||||
pub catalog: CatalogMirror,
|
||||
pub broker: BrokerHandle,
|
||||
pub self_daemon_id: Arc<String>,
|
||||
pub self_perimeter_id: Option<Arc<String>>,
|
||||
}
|
||||
|
||||
pub fn broker_router(state: BrokerState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/attach/:id", any(broker_attach_ws))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn broker_attach_ws(
|
||||
Path(id): Path<Uuid>,
|
||||
State(state): State<BrokerState>,
|
||||
ws: axum::extract::WebSocketUpgrade,
|
||||
) -> Result<Response, Response> {
|
||||
if state.manager.has(id).await {
|
||||
// Local short-circuit.
|
||||
let manager = state.manager.clone();
|
||||
return Ok(ws.on_upgrade(move |socket| async move {
|
||||
let _ = crate::attach::serve_local(socket, manager, id).await;
|
||||
}));
|
||||
}
|
||||
|
||||
// Look up which peer owns the session via the catalog mirror.
|
||||
let peer = state
|
||||
.catalog
|
||||
.sessions()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|s| s.daemon_id);
|
||||
let Some(peer_daemon_id) = peer else {
|
||||
return Err(error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_session",
|
||||
"no such session on this daemon or any known peer",
|
||||
));
|
||||
};
|
||||
|
||||
// ResolveAttach via the control link.
|
||||
let candidates = match state.broker.resolve_attach(id).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(%id, %peer_daemon_id, error = %e, "ResolveAttach failed");
|
||||
let (code, msg) = broker_error_code(&e);
|
||||
return Err(error_response(StatusCode::BAD_GATEWAY, code, &msg));
|
||||
}
|
||||
};
|
||||
|
||||
// Pick a transport. T1 is canceled (NAP-235); T3 is gated (NAP-238).
|
||||
// For T2 we need both daemons to declare the same perimeter id, and the
|
||||
// candidate must include a private address.
|
||||
let perimeter = candidates.perimeter.clone();
|
||||
let our_perimeter = state.self_perimeter_id.as_deref().map(|s| s.as_str());
|
||||
let dial_url = match (perimeter, our_perimeter) {
|
||||
(Some(p), Some(ours)) if p.perimeter_id == ours => {
|
||||
format!(
|
||||
"ws://{}/sessions/{}/stream?token={}",
|
||||
p.private_addr,
|
||||
id,
|
||||
urlencoding::encode(&candidates.jwt),
|
||||
)
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(error_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"perimeter_mismatch",
|
||||
"peer is on a different perimeter; T2 unavailable, T1/T3 not implemented",
|
||||
));
|
||||
}
|
||||
(Some(_), None) => {
|
||||
return Err(error_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"no_local_perimeter",
|
||||
"this daemon has no perimeter_id set; can't use T2",
|
||||
));
|
||||
}
|
||||
(None, _) => {
|
||||
return Err(error_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"no_perimeter_candidate",
|
||||
"peer hasn't advertised a perimeter; T2 unavailable",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ws.on_upgrade(move |socket| async move {
|
||||
let _ = pump_broker_ws(socket, dial_url).await;
|
||||
}))
|
||||
}
|
||||
|
||||
/// Bidirectional pump between the client WS and the peer daemon's WS.
|
||||
async fn pump_broker_ws(
|
||||
client: axum::extract::ws::WebSocket,
|
||||
upstream_url: String,
|
||||
) -> Result<(), axum::Error> {
|
||||
use axum::extract::ws::Message as AxMessage;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
|
||||
let (upstream, _) = match tokio_tungstenite::connect_async(&upstream_url).await {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
tracing::warn!(url = %upstream_url, error = %e, "T2 dial failed");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
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",
|
||||
_ => "registry_error",
|
||||
},
|
||||
msg.clone(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +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. Matches the registry's
|
||||
/// `EVENT_CHANNEL_CAPACITY` so a slow client behaves the same on either side.
|
||||
const BUS_CAPACITY: usize = 256;
|
||||
|
||||
/// 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//! `maestrod` — the Maestro per-host daemon binary.
|
||||
//!
|
||||
//! Two subcommands:
|
||||
//! - `maestrod` / `maestrod run` — start the daemon.
|
||||
//! - `maestrod configure` — bootstrap the config file.
|
||||
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use maestro_daemon::session::DEFAULT_LIVENESS_INTERVAL;
|
||||
use maestro_daemon::sidecar::SidecarStore;
|
||||
use maestro_daemon::tmux::Tmux;
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
use maestro_daemon::{
|
||||
attach, default_config_path, default_state_dir, load_config, local_api, run_configure,
|
||||
spawn_session_publisher, ConfigureInputs, EnvOverrides, JwtVerifier, LocalEventBus,
|
||||
PairAttachVerifier, RegistryClient, SessionManager,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "maestrod",
|
||||
version,
|
||||
about = "Per-host Maestro daemon: tmux wrapper + registry client"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Override the unified Maestro config-file path (default `~/.config/maestro/config.toml`).
|
||||
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Start the daemon. Default when no subcommand is given.
|
||||
Run,
|
||||
/// Write `~/.config/maestro/config.toml` interactively or from flags.
|
||||
Configure(ConfigureCmd),
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
struct ConfigureCmd {
|
||||
/// Registry base URL (e.g. `https://maestro.example.com`). The daemon
|
||||
/// derives its WS endpoint by scheme-swapping and appending `/daemon`.
|
||||
#[arg(long)]
|
||||
registry_url: Option<String>,
|
||||
/// Reachable URL clients dial for this daemon's attach WS.
|
||||
#[arg(long)]
|
||||
daemon_url: Option<String>,
|
||||
/// Shared token (MAESTRO_TOKEN). Tip: pipe from `pass` / `op` / `secret-tool`.
|
||||
#[arg(long)]
|
||||
token: Option<String>,
|
||||
/// Stable daemon ID. Defaults to the system hostname.
|
||||
#[arg(long)]
|
||||
daemon_id: Option<String>,
|
||||
/// Local bind address for the per-session WS endpoint.
|
||||
#[arg(long)]
|
||||
bind_addr: Option<String>,
|
||||
/// Override the daemon's state directory.
|
||||
#[arg(long)]
|
||||
state_dir: Option<PathBuf>,
|
||||
/// Override the worktree root.
|
||||
#[arg(long)]
|
||||
worktree_root: Option<PathBuf>,
|
||||
/// Override the recorded hostname (defaults to `gethostname(2)`).
|
||||
#[arg(long)]
|
||||
hostname: Option<String>,
|
||||
/// Overwrite an existing config file.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Disable interactive prompts (errors if any required field is missing).
|
||||
#[arg(long)]
|
||||
non_interactive: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
|
||||
let result: Result<(), Box<dyn Error>> = match cli.command.unwrap_or(Command::Run) {
|
||||
Command::Run => run(cli.config).await,
|
||||
Command::Configure(args) => configure(cli.config, args),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "maestrod failed");
|
||||
eprintln!("maestrod: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 config = load_config(&config_path, EnvOverrides::from_env())?;
|
||||
tracing::info!(
|
||||
daemon_id = %config.daemon_id,
|
||||
registry = %config.registry_url,
|
||||
bind = %config.bind_addr,
|
||||
"loaded config"
|
||||
);
|
||||
|
||||
let _ = default_state_dir(); // surface XDG diagnostics early but don't override
|
||||
std::fs::create_dir_all(config.sidecar_dir())?;
|
||||
std::fs::create_dir_all(config.fifo_dir())?;
|
||||
std::fs::create_dir_all(&config.worktree_root)?;
|
||||
|
||||
let sidecar = SidecarStore::new(config.sidecar_dir());
|
||||
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
|
||||
let tmux = Tmux::new();
|
||||
let manager = SessionManager::new_with_replay(
|
||||
config.daemon_id.clone(),
|
||||
tmux,
|
||||
sidecar,
|
||||
worktree_mgr,
|
||||
config.fifo_dir(),
|
||||
config.pty_replay_bytes,
|
||||
);
|
||||
|
||||
let recovered = manager.recover().await?;
|
||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
|
||||
|
||||
let verifier = JwtVerifier::new(&config.token, &config.daemon_id);
|
||||
let pair_verifier = PairAttachVerifier::new(
|
||||
&config.token,
|
||||
&config.daemon_id,
|
||||
config.allow_inbound.iter().cloned(),
|
||||
);
|
||||
|
||||
// Daemon-local event bus (NAP-248): own session events + peer catalog
|
||||
// deltas funnel through here for `WS /api/events` subscribers.
|
||||
let events = LocalEventBus::new();
|
||||
spawn_session_publisher(manager.clone(), events.clone());
|
||||
|
||||
// Build the registry client first so we can hand its catalog mirror to
|
||||
// the local-API state — both halves share the same Arc<Mutex<…>>. Hand
|
||||
// the event bus to the catalog mirror so peer deltas fan out too.
|
||||
let client = RegistryClient::new_with_bus(
|
||||
config.registry_url.clone(),
|
||||
config.daemon_id.clone(),
|
||||
config.hostname.clone(),
|
||||
config.daemon_url.clone(),
|
||||
config.token.clone(),
|
||||
manager.clone(),
|
||||
Some(events.clone()),
|
||||
)
|
||||
.with_perimeter(config.perimeter_id.clone(), config.private_addr.clone());
|
||||
let catalog = client.catalog();
|
||||
|
||||
let local_state = local_api::state_for(
|
||||
manager.clone(),
|
||||
config.hostname.clone(),
|
||||
config.daemon_url.clone(),
|
||||
catalog.clone(),
|
||||
events,
|
||||
);
|
||||
let broker_state = local_api::BrokerState {
|
||||
manager: manager.clone(),
|
||||
catalog,
|
||||
broker: client.broker(),
|
||||
self_daemon_id: std::sync::Arc::new(config.daemon_id.clone()),
|
||||
self_perimeter_id: config.perimeter_id.clone().map(std::sync::Arc::new),
|
||||
};
|
||||
let router = attach::router(manager.clone(), verifier, pair_verifier)
|
||||
.merge(local_api::router(local_state))
|
||||
.merge(local_api::broker_router(broker_state));
|
||||
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
let bound = listener.local_addr()?;
|
||||
tracing::info!(addr = %bound, "attach WS listening");
|
||||
|
||||
tokio::spawn(async move { client.run().await });
|
||||
|
||||
let shutdown = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("ctrl-c received — shutting down");
|
||||
};
|
||||
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure(config_override: Option<PathBuf>, args: ConfigureCmd) -> 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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Best-effort `/proc` inspection for the activity watcher (NAP-340, Linux).
|
||||
//!
|
||||
//! Decides whether a session is **blocked waiting on the user**: ask the kernel
|
||||
//! which process group owns the terminal, then check whether any live member of
|
||||
//! that group is parked on the terminal.
|
||||
//!
|
||||
//! `/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
|
||||
//! *unprivileged* signals instead:
|
||||
//!
|
||||
//! 1. process state `S` (interruptible sleep — where a blocking `read()` parks),
|
||||
//! 2. its `wchan` is not a pure timer sleep (so `sleep 30`, which also sits in
|
||||
//! `S` with the terminal inherited, is excluded), and
|
||||
//! 3. one of its stdio fds is the pane's tty.
|
||||
//!
|
||||
//! Every step degrades to `false` on a read failure, so a non-Linux host or a
|
||||
//! denied `/proc` read falls back to `Idle`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// One walk of `/proc`, shared by every session in an activity pass.
|
||||
///
|
||||
/// 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
|
||||
// (running, uninterruptible) is busy, not waiting on us.
|
||||
if groups.state(pid) != Some('S') {
|
||||
return false;
|
||||
}
|
||||
// A pure timer sleep (`sleep`, `usleep`) also sits in 'S' with the terminal
|
||||
// inherited — exclude it so it reads as Idle, not AwaitingInput.
|
||||
if is_timer_sleep(pid) {
|
||||
return false;
|
||||
}
|
||||
fd_points_at_tty(pid, tty)
|
||||
}
|
||||
|
||||
/// The fields of `/proc/<pid>/stat` this module needs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct Stat {
|
||||
state: char,
|
||||
/// The process group this process belongs to.
|
||||
pgrp: i32,
|
||||
/// Foreground process group of the controlling terminal.
|
||||
tpgid: i32,
|
||||
}
|
||||
|
||||
fn read_stat(pid: i32) -> Option<Stat> {
|
||||
parse_stat(&std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)
|
||||
}
|
||||
|
||||
/// Parse the fields after `comm` from a `/proc/<pid>/stat` line.
|
||||
///
|
||||
/// 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.
|
||||
fn parse_stat(stat: &str) -> Option<Stat> {
|
||||
let close = stat.rfind(')')?;
|
||||
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
|
||||
/// sleep — `hrtimer_nanosleep`, `do_nanosleep`, etc. Those are quiescent but
|
||||
/// *not* waiting on the user.
|
||||
fn is_timer_sleep(pid: i32) -> bool {
|
||||
std::fs::read_to_string(format!("/proc/{pid}/wchan"))
|
||||
.map(|w| is_timer_wchan(&w))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Classify a `wchan` symbol as a pure timer sleep. Kept pure for testing.
|
||||
fn is_timer_wchan(wchan: &str) -> bool {
|
||||
wchan.contains("nanosleep")
|
||||
}
|
||||
|
||||
/// Whether any of `pid`'s stdio fds (0/1/2) is the terminal `tty`.
|
||||
fn fd_points_at_tty(pid: i32, tty: &str) -> bool {
|
||||
(0..3).any(|fd| {
|
||||
std::fs::read_link(format!("/proc/{pid}/fd/{fd}"))
|
||||
.map(|target| target.to_string_lossy() == tty)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `pid (comm) state ppid pgrp session tty_nr tpgid …`
|
||||
fn stat_line(comm: &str, state: char, tpgid: i32) -> String {
|
||||
stat_line_pgrp(comm, state, 1234, tpgid)
|
||||
}
|
||||
|
||||
fn stat_line_pgrp(comm: &str, state: char, pgrp: i32, tpgid: i32) -> String {
|
||||
format!("1234 ({comm}) {state} 1000 {pgrp} 1234 34816 {tpgid} 4194304 …")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stat_reads_state_pgrp_and_tpgid() {
|
||||
let s = parse_stat(&stat_line_pgrp("bash", 'S', 500, 4321)).unwrap();
|
||||
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]
|
||||
fn timer_wchan_excludes_sleep_but_not_a_tty_wait() {
|
||||
// `sleep` blocks here — excluded.
|
||||
assert!(is_timer_wchan("hrtimer_nanosleep"));
|
||||
assert!(is_timer_wchan("do_nanosleep"));
|
||||
// A terminal read / poll parks here — not a timer.
|
||||
assert!(!is_timer_wchan("wait_woken"));
|
||||
assert!(!is_timer_wchan("n_tty_read"));
|
||||
assert!(!is_timer_wchan("do_select"));
|
||||
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]
|
||||
fn awaiting_input_is_false_for_nonexistent_pid() {
|
||||
// A pid that can't exist → not in the snapshot → false (falls back to Idle).
|
||||
assert!(!awaiting_input(
|
||||
i32::MAX,
|
||||
"/dev/pts/0",
|
||||
&ProcGroups::snapshot()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Project discovery for `GET /api/projects` (#54).
|
||||
//!
|
||||
//! Scans `[spawn].project_roots` (default the whole home) for `.git` entries —
|
||||
//! directory or file, so linked worktrees count too — up to
|
||||
//! `[spawn].project_scan_depth` levels. Descent stops at the first repo found:
|
||||
//! nested independent repos are not discovered, but worktrees of every hit
|
||||
//! still are, via `git worktree list`, which also catches linked worktrees
|
||||
//! living outside the roots.
|
||||
//!
|
||||
//! Hidden directories are skipped everywhere; heavy well-known directories
|
||||
//! (`node_modules`, `Downloads`, …) are skipped when they sit directly in the
|
||||
//! home directory, so scanning all of home stays cheap.
|
||||
//!
|
||||
//! Results are cached behind a short TTL and refreshed on a slow background
|
||||
//! timer ([`ProjectScanner::start_background_refresh`]) — repositories appear
|
||||
//! rarely, and `/api/projects` may be polled.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::spawn_history::RecentDir;
|
||||
|
||||
/// Directory names never descended when they sit directly in the home dir, so
|
||||
/// scanning all of home stays cheap (#54). Hidden dirs are skipped everywhere
|
||||
/// regardless.
|
||||
pub const DEFAULT_HOME_IGNORE: &[&str] =
|
||||
&["node_modules", "Downloads", "Library", "go", "snap", "opt"];
|
||||
|
||||
/// How long a discovery snapshot is served as-is before the next request
|
||||
/// triggers a rescan.
|
||||
///
|
||||
/// A scan walks the roots and runs one `git worktree list` per hit, holding
|
||||
/// the scanner lock throughout, and clients re-request whenever a draft pane
|
||||
/// re-mounts — which `multiview.render()` does on unrelated events. Keep this
|
||||
/// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is
|
||||
/// what refreshes, and a request only rescans when that timer has failed to.
|
||||
pub const CACHE_TTL: Duration = Duration::from_secs(180);
|
||||
/// Background refresh cadence. Repositories appear rarely; this keeps the
|
||||
/// served list eventually fresh without anyone asking.
|
||||
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// One repository or linked worktree found by [`discover`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiscoveredRepo {
|
||||
pub path: PathBuf,
|
||||
/// Basename of `path`.
|
||||
pub name: String,
|
||||
pub is_worktree: bool,
|
||||
/// The main checkout this worktree belongs to; set only when
|
||||
/// [`Self::is_worktree`].
|
||||
pub base_repo: Option<PathBuf>,
|
||||
/// Mtime of the checkout's git HEAD — "someone committed / checked out
|
||||
/// here". The later-of merge with spawn history happens in
|
||||
/// [`to_projects`], which also sorts.
|
||||
pub head_mtime: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Repo toplevels under each root of `roots`, at most `max_depth` levels deep.
|
||||
/// A dir holding `.git` (dir *or* file) is a hit and is not descended into;
|
||||
/// hidden directories are never entered, and `DEFAULT_HOME_IGNORE` names are
|
||||
/// skipped directly under `home`.
|
||||
pub fn scan_roots(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<PathBuf> {
|
||||
let mut found = Vec::new();
|
||||
for root in roots {
|
||||
walk(root, 0, max_depth, home, &mut found);
|
||||
}
|
||||
found.sort();
|
||||
found.dedup();
|
||||
found
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, depth: u32, max_depth: u32, home: &Path, found: &mut Vec<PathBuf>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut subdirs = Vec::new();
|
||||
let mut has_git = false;
|
||||
for entry in entries.flatten() {
|
||||
match entry.file_name().to_string_lossy().into_owned() {
|
||||
name if name == ".git" => has_git = true,
|
||||
name if name.starts_with('.') => {}
|
||||
name if dir == home && DEFAULT_HOME_IGNORE.contains(&name.as_str()) => {}
|
||||
_ => {
|
||||
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
|
||||
subdirs.push(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_git {
|
||||
// Match `git worktree list` output, which prints real paths.
|
||||
found.push(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
|
||||
return;
|
||||
}
|
||||
if depth >= max_depth {
|
||||
return;
|
||||
}
|
||||
for sub in subdirs {
|
||||
walk(&sub, depth + 1, max_depth, home, found);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every discovered project: the scanned repos plus all worktrees their
|
||||
/// `git worktree list` reports (linked worktrees can live outside the roots).
|
||||
/// A hit whose git invocation fails still lists, un-worktreed.
|
||||
pub fn discover(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<DiscoveredRepo> {
|
||||
let scanned = scan_roots(roots, max_depth, home);
|
||||
let mut map: Vec<(PathBuf, PathBuf)> = Vec::new(); // (checkout, main)
|
||||
for repo in scanned {
|
||||
if map.iter().any(|(c, _)| c == &repo) {
|
||||
continue;
|
||||
}
|
||||
let worktrees = list_worktrees(&repo).unwrap_or_else(|| vec![repo.clone()]);
|
||||
let main = worktrees[0].clone();
|
||||
for wt in worktrees {
|
||||
if !map.iter().any(|(c, _)| c == &wt) {
|
||||
map.push((wt, main.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
map.into_iter()
|
||||
.map(|(path, main)| {
|
||||
let is_worktree = path != main;
|
||||
DiscoveredRepo {
|
||||
name: path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
is_worktree,
|
||||
base_repo: is_worktree.then_some(main),
|
||||
head_mtime: head_mtime(&path),
|
||||
path,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `git worktree list --porcelain` from `repo`: absolute paths, main first.
|
||||
/// `None` when git fails (broken/bare checkout).
|
||||
fn list_worktrees(repo: &Path) -> Option<Vec<PathBuf>> {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
if let Some(p) = line.strip_prefix("worktree ") {
|
||||
paths.push(PathBuf::from(p));
|
||||
}
|
||||
}
|
||||
(!paths.is_empty()).then_some(paths)
|
||||
}
|
||||
|
||||
/// Mtime of the checkout's reflog, falling back to `HEAD` itself.
|
||||
///
|
||||
/// `HEAD` is only rewritten on checkout — committing updates
|
||||
/// `refs/heads/<branch>` and appends to `logs/HEAD`, leaving `HEAD` alone. A
|
||||
/// repo worked on daily from one long-lived branch would otherwise report its
|
||||
/// last *branch switch* as its activity and get dimmed as stale (#54 review).
|
||||
///
|
||||
/// For a linked worktree `.git` is a file holding a `gitdir:` pointer;
|
||||
/// resolve it so worktrees report their own reflog rather than nothing.
|
||||
fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
|
||||
let dot_git = repo.join(".git");
|
||||
let head = if dot_git.is_dir() {
|
||||
dot_git.join("HEAD")
|
||||
} else {
|
||||
let ptr = std::fs::read_to_string(&dot_git).ok()?;
|
||||
let gitdir = ptr.trim().strip_prefix("gitdir:")?.trim();
|
||||
// Relative pointers are relative to the worktree.
|
||||
let p = Path::new(gitdir);
|
||||
let p = if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
repo.join(p)
|
||||
};
|
||||
p.join("HEAD")
|
||||
};
|
||||
// Prefer the reflog next to whichever HEAD we resolved.
|
||||
let reflog = head.with_file_name("logs").join("HEAD");
|
||||
let head = if reflog.exists() { reflog } else { head };
|
||||
let mtime = std::fs::metadata(head).ok()?.modified().ok()?;
|
||||
Some(DateTime::<Utc>::from(mtime))
|
||||
}
|
||||
|
||||
/// Fold spawn history into the discovery snapshot and produce the wire list,
|
||||
/// sorted newest-activity-first. `history` must be newest-first (what
|
||||
/// [`SpawnHistory::all_recent_dirs`](crate::spawn_history::SpawnHistory)
|
||||
/// returns): the first entry whose cwd sits inside a project is its most
|
||||
/// recent use.
|
||||
pub fn to_projects(
|
||||
repos: &[DiscoveredRepo],
|
||||
history: &[RecentDir],
|
||||
) -> Vec<maestro_protocol::Project> {
|
||||
let mut projects: Vec<maestro_protocol::Project> = repos
|
||||
.iter()
|
||||
.map(|repo| {
|
||||
let last_use = history.iter().find(|h| contains(&repo.path, &h.cwd));
|
||||
maestro_protocol::Project {
|
||||
path: repo.path.clone(),
|
||||
name: repo.name.clone(),
|
||||
is_worktree: repo.is_worktree,
|
||||
base_repo: repo.base_repo.clone(),
|
||||
last_activity: max_opt(repo.head_mtime, last_use.map(|u| u.last_used)),
|
||||
last_command: last_use.map(|u| u.last_command.clone()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
projects.sort_by_key(|p| std::cmp::Reverse(p.last_activity));
|
||||
projects
|
||||
}
|
||||
|
||||
/// Whether `dir` is `project` itself or anywhere inside it.
|
||||
fn contains(project: &Path, dir: &Path) -> bool {
|
||||
dir == project || dir.starts_with(project)
|
||||
}
|
||||
|
||||
fn max_opt(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Some(a.max(b)),
|
||||
(a, b) => a.or(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached discovery behind a short TTL plus an optional slow background
|
||||
/// refresh. Clone-free shared handle via `Arc` in [`crate::local_api`].
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectScanner {
|
||||
roots: Vec<PathBuf>,
|
||||
max_depth: u32,
|
||||
home: PathBuf,
|
||||
ttl: Duration,
|
||||
cache: tokio::sync::Mutex<Option<(Instant, std::sync::Arc<Vec<DiscoveredRepo>>)>>,
|
||||
}
|
||||
|
||||
impl ProjectScanner {
|
||||
/// Scanner over the resolved config roots. An empty `roots` list scans
|
||||
/// nothing and always serves an empty list.
|
||||
pub fn new(roots: Vec<PathBuf>, max_depth: u32, home: PathBuf) -> Self {
|
||||
Self {
|
||||
roots,
|
||||
max_depth,
|
||||
home,
|
||||
ttl: CACHE_TTL,
|
||||
cache: tokio::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scanner that never discovers anything — tests and embedders that don't
|
||||
/// want the endpoint populated.
|
||||
pub fn disabled() -> Self {
|
||||
Self::new(Vec::new(), 0, PathBuf::new())
|
||||
}
|
||||
|
||||
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
||||
/// Also the background timer's entry point: a tick inside the TTL is a
|
||||
/// no-op (#75).
|
||||
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||
let mut cache = self.cache.lock().await;
|
||||
if let Some((at, repos)) = cache.as_ref() {
|
||||
if at.elapsed() < self.ttl {
|
||||
return repos.clone();
|
||||
}
|
||||
}
|
||||
let roots = self.roots.clone();
|
||||
let max_depth = self.max_depth;
|
||||
let home = self.home.clone();
|
||||
// Blocking fs walk + git subprocesses must not stall the reactor.
|
||||
let repos = std::sync::Arc::new(
|
||||
tokio::task::spawn_blocking(move || discover(&roots, max_depth, &home))
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
*cache = Some((Instant::now(), repos.clone()));
|
||||
repos
|
||||
}
|
||||
|
||||
/// One background-refresh tick: rescan only if the cached snapshot has
|
||||
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
|
||||
/// roots every [`REFRESH_INTERVAL`].
|
||||
async fn refresh(&self) {
|
||||
self.repos().await;
|
||||
}
|
||||
|
||||
/// Spawn the slow background refresh task. Drop the returned handle to
|
||||
/// detach it; it stops when the runtime shuts down.
|
||||
pub fn start_background_refresh(self: &std::sync::Arc<Self>) -> tokio::task::JoinHandle<()> {
|
||||
let scanner = std::sync::Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(REFRESH_INTERVAL);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
ticker.tick().await; // the immediate first tick — wait a full interval
|
||||
loop {
|
||||
scanner.refresh().await;
|
||||
ticker.tick().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::spawn_history::RecentDir;
|
||||
|
||||
/// Real git repo at `<root>/<name>`, one initial commit so HEAD exists.
|
||||
fn make_repo(root: &Path, name: &str) -> PathBuf {
|
||||
let path = root.join(name);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
run_git(&path, &["init", "-q"]);
|
||||
std::fs::write(path.join("f.txt"), "x\n").unwrap();
|
||||
run_git(&path, &["add", "-A"]);
|
||||
run_git(
|
||||
&path,
|
||||
&[
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"init",
|
||||
],
|
||||
);
|
||||
path
|
||||
}
|
||||
|
||||
fn run_git(dir: &Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {} | {}",
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
);
|
||||
}
|
||||
|
||||
fn names(repos: &[DiscoveredRepo]) -> Vec<String> {
|
||||
let mut v: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_repos_and_stops_at_the_first_hit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let outer = make_repo(tmp.path(), "outer");
|
||||
// A nested independent repo inside `outer` is NOT discovered
|
||||
// separately: descent stops once a repo is found.
|
||||
make_repo(&outer, "vendored");
|
||||
std::fs::create_dir_all(tmp.path().join("plain/deep/deeper")).unwrap();
|
||||
|
||||
let found = scan_roots(
|
||||
&[tmp.path().to_path_buf()],
|
||||
3,
|
||||
Path::new("/nonexistent-home"),
|
||||
);
|
||||
assert_eq!(found, vec![outer]);
|
||||
|
||||
let discovered = discover(
|
||||
&[tmp.path().to_path_buf()],
|
||||
3,
|
||||
Path::new("/nonexistent-home"),
|
||||
);
|
||||
assert_eq!(names(&discovered), ["outer"]);
|
||||
assert!(!discovered[0].is_worktree);
|
||||
assert_eq!(discovered[0].base_repo, None);
|
||||
assert!(discovered[0].head_mtime.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_respects_depth_hidden_dirs_and_home_ignore() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("home");
|
||||
|
||||
// Hidden dirs are never entered, at any depth.
|
||||
std::fs::create_dir_all(home.join(".secret/.git")).unwrap();
|
||||
// node_modules directly under home is not descended…
|
||||
let _ignored = make_repo(&home.join("node_modules"), "dep");
|
||||
// …but the same name deeper down is fair game.
|
||||
let kept = make_repo(&home.join("code/node_modules"), "dep2");
|
||||
|
||||
// Depth cap: a repo three levels below the root is the last tier —
|
||||
// found at depth 3, but a fourth-level repo isn't.
|
||||
let at_three = home.join("a/b/c");
|
||||
std::fs::create_dir_all(at_three.join(".git")).unwrap();
|
||||
std::fs::create_dir_all(home.join("a/b/c/d/.git")).unwrap();
|
||||
|
||||
let found = scan_roots(std::slice::from_ref(&home), 3, &home);
|
||||
assert!(found.iter().all(|p| !p.starts_with(home.join(".secret"))));
|
||||
assert!(found
|
||||
.iter()
|
||||
.all(|p| !p.starts_with(home.join("node_modules"))));
|
||||
assert!(found.contains(&kept.canonicalize().unwrap()));
|
||||
assert!(found.contains(&at_three.canonicalize().unwrap()));
|
||||
assert!(!found.iter().any(|p| p.ends_with("d")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktrees_outside_the_roots_are_discovered_via_git_worktree_list() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("scan-root");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let main = make_repo(&root, "main-repo");
|
||||
// Linked worktree deliberately outside the scan root.
|
||||
let wt = tmp.path().join("elsewhere/wt-of-main");
|
||||
std::fs::create_dir_all(wt.parent().unwrap()).unwrap();
|
||||
run_git(
|
||||
&main,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"feature",
|
||||
&wt.to_string_lossy(),
|
||||
],
|
||||
);
|
||||
|
||||
let discovered = discover(&[root], 3, Path::new("/nonexistent-home"));
|
||||
assert_eq!(names(&discovered), ["main-repo", "wt-of-main"]);
|
||||
let wt_entry = discovered.iter().find(|d| d.name == "wt-of-main").unwrap();
|
||||
assert!(wt_entry.is_worktree);
|
||||
assert_eq!(
|
||||
wt_entry.base_repo.as_deref(),
|
||||
Some(main.canonicalize().unwrap().as_path())
|
||||
);
|
||||
let main_entry = discovered.iter().find(|d| d.name == "main-repo").unwrap();
|
||||
assert!(!main_entry.is_worktree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_mtime_resolves_a_worktrees_git_file_pointer() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let main = make_repo(tmp.path(), "m");
|
||||
let wt = tmp.path().join("wt");
|
||||
run_git(
|
||||
&main,
|
||||
&["worktree", "add", "-q", "-b", "b2", &wt.to_string_lossy()],
|
||||
);
|
||||
assert!(head_mtime(&wt).is_some());
|
||||
}
|
||||
|
||||
fn recent(cwd: &str, cmd: &str, at: &str) -> RecentDir {
|
||||
RecentDir {
|
||||
cwd: PathBuf::from(cwd),
|
||||
last_command: cmd.into(),
|
||||
last_used: DateTime::parse_from_rfc3339(at)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_projects_merges_history_and_sorts_newest_first() {
|
||||
let repo_old = DiscoveredRepo {
|
||||
path: PathBuf::from("/w/old"),
|
||||
name: "old".into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
// Committed recently but never spawned in.
|
||||
head_mtime: Some(t("2026-08-23T10:00:00Z")),
|
||||
};
|
||||
let repo_hist = DiscoveredRepo {
|
||||
path: PathBuf::from("/w/hist"),
|
||||
name: "hist".into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
// Old commit…
|
||||
head_mtime: Some(t("2026-08-01T10:00:00Z")),
|
||||
};
|
||||
let repo_stale = DiscoveredRepo {
|
||||
path: PathBuf::from("/w/stale"),
|
||||
name: "stale".into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
head_mtime: None,
|
||||
};
|
||||
|
||||
let history = vec![
|
||||
// Newest overall: a subdir of /w/hist (first match wins for it).
|
||||
recent("/w/hist/crates", "opencode", "2026-08-22T09:00:00Z"),
|
||||
recent("/w/hist", "claude", "2026-08-20T09:00:00Z"),
|
||||
];
|
||||
let projects = to_projects(&[repo_old, repo_hist, repo_stale], &history);
|
||||
|
||||
let paths: Vec<&str> = projects.iter().map(|p| p.path.to_str().unwrap()).collect();
|
||||
assert_eq!(paths, ["/w/old", "/w/hist", "/w/stale"]);
|
||||
|
||||
// old: git activity wins (never spawned).
|
||||
assert_eq!(projects[0].last_activity, Some(t("2026-08-23T10:00:00Z")));
|
||||
assert_eq!(projects[0].last_command, None);
|
||||
// hist: spawn history beats the old commit; command from the newest
|
||||
// matching entry (the crates/ one, not the repo root one).
|
||||
assert_eq!(projects[1].last_activity, Some(t("2026-08-22T09:00:00Z")));
|
||||
assert_eq!(projects[1].last_command.as_deref(), Some("opencode"));
|
||||
// stale: neither source knows anything.
|
||||
assert_eq!(projects[2].last_activity, None);
|
||||
assert_eq!(projects[2].last_command, None);
|
||||
}
|
||||
|
||||
fn t(s: &str) -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_caches_within_ttl_and_rescans_after() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut scanner = ProjectScanner::new(
|
||||
vec![tmp.path().to_path_buf()],
|
||||
3,
|
||||
PathBuf::from("/nonexistent"),
|
||||
);
|
||||
|
||||
let empty = scanner.repos().await;
|
||||
assert!(empty.is_empty());
|
||||
|
||||
make_repo(tmp.path(), "late");
|
||||
// Within the TTL the cached (empty) snapshot is still served.
|
||||
let still_empty = scanner.repos().await;
|
||||
assert!(still_empty.is_empty());
|
||||
|
||||
// After the TTL lapses the rescan sees the new repo.
|
||||
scanner.ttl = Duration::ZERO;
|
||||
let fresh = scanner.repos().await;
|
||||
assert_eq!(fresh.len(), 1);
|
||||
assert_eq!(fresh[0].name, "late");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_refresh_honours_the_ttl() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut scanner = ProjectScanner::new(
|
||||
vec![tmp.path().to_path_buf()],
|
||||
3,
|
||||
PathBuf::from("/nonexistent"),
|
||||
);
|
||||
|
||||
let empty = scanner.repos().await;
|
||||
assert!(empty.is_empty());
|
||||
|
||||
make_repo(tmp.path(), "late");
|
||||
// A background tick inside the TTL must not re-walk the filesystem:
|
||||
// the new repo stays invisible until the cache expires.
|
||||
scanner.refresh().await;
|
||||
assert!(scanner.repos().await.is_empty());
|
||||
|
||||
// Once the TTL lapses, the next tick picks it up.
|
||||
scanner.ttl = Duration::ZERO;
|
||||
scanner.refresh().await;
|
||||
let fresh = scanner.repos().await;
|
||||
assert_eq!(fresh.len(), 1);
|
||||
assert_eq!(fresh[0].name, "late");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_scanner_always_serves_empty() {
|
||||
let scanner = ProjectScanner::disabled();
|
||||
assert_eq!(scanner.roots, Vec::<PathBuf>::new());
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use tokio::io::unix::AsyncFd;
|
||||
use tokio::io::Interest;
|
||||
@@ -38,8 +39,9 @@ use tokio::task::JoinHandle;
|
||||
|
||||
/// How many recent PTY frames each new subscriber inherits before starting to
|
||||
/// receive live frames. A small buffer is fine — clients render a fresh
|
||||
/// terminal anyway.
|
||||
pub const PTY_BROADCAST_CAPACITY: usize = 256;
|
||||
/// terminal anyway. Sourced from [`maestro_config::PTY_CAPACITY`]
|
||||
/// (NAP-273).
|
||||
pub const PTY_BROADCAST_CAPACITY: usize = maestro_config::PTY_CAPACITY;
|
||||
|
||||
/// Default replay-buffer size — roughly one screenful for a typical 24×80
|
||||
/// terminal plus reasonable scrollback. Override via
|
||||
@@ -56,8 +58,14 @@ const READ_BUF_BYTES: usize = 4096;
|
||||
#[derive(Debug)]
|
||||
pub struct PtyBridge {
|
||||
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,
|
||||
/// Monotonic timestamp of the last non-empty read off the FIFO, bumped by
|
||||
/// `run_reader`. The activity watcher reads it to gauge PTY quiescence
|
||||
/// without touching the byte stream (NAP-340).
|
||||
last_activity: Arc<Mutex<Instant>>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -87,17 +95,29 @@ impl PtyBridge {
|
||||
let replay = ReplayBuffer::new(replay_bytes);
|
||||
let tx = sender.clone();
|
||||
let replay_for_task = replay.clone();
|
||||
let last_activity = Arc::new(Mutex::new(Instant::now()));
|
||||
let last_activity_for_task = last_activity.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
run_reader(async_fd, tx, replay_for_task).await;
|
||||
run_reader(async_fd, tx, replay_for_task, last_activity_for_task).await;
|
||||
});
|
||||
Ok(Self {
|
||||
fifo_path,
|
||||
sender,
|
||||
sender: Some(sender),
|
||||
replay,
|
||||
last_activity,
|
||||
task,
|
||||
})
|
||||
}
|
||||
|
||||
/// How long since the session last produced PTY output. The activity
|
||||
/// watcher treats a short value as `Active` (NAP-340).
|
||||
pub fn idle_for(&self) -> std::time::Duration {
|
||||
self.last_activity
|
||||
.lock()
|
||||
.expect("last_activity poisoned")
|
||||
.elapsed()
|
||||
}
|
||||
|
||||
pub fn fifo_path(&self) -> &Path {
|
||||
&self.fifo_path
|
||||
}
|
||||
@@ -105,7 +125,14 @@ impl PtyBridge {
|
||||
/// Subscribe to live PTY frames. Use [`Self::subscribe_with_replay`] for
|
||||
/// new attaches that want the buffered scrollback first.
|
||||
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
|
||||
@@ -116,10 +143,30 @@ impl PtyBridge {
|
||||
pub fn subscribe_with_replay(&self) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>) {
|
||||
let guard = self.replay.lock_for_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);
|
||||
(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 {
|
||||
@@ -196,7 +243,12 @@ impl ReplayGuard<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_reader(async_fd: AsyncFd<File>, tx: broadcast::Sender<Vec<u8>>, replay: ReplayBuffer) {
|
||||
async fn run_reader(
|
||||
async_fd: AsyncFd<File>,
|
||||
tx: broadcast::Sender<Vec<u8>>,
|
||||
replay: ReplayBuffer,
|
||||
last_activity: Arc<Mutex<Instant>>,
|
||||
) {
|
||||
let mut buf = [0u8; READ_BUF_BYTES];
|
||||
loop {
|
||||
let mut guard = match async_fd.readable().await {
|
||||
@@ -218,6 +270,9 @@ async fn run_reader(async_fd: AsyncFd<File>, tx: broadcast::Sender<Vec<u8>>, rep
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
Ok(Ok(n)) => {
|
||||
// Mark the session as having just produced output, for the
|
||||
// activity watcher's quiescence check (NAP-340).
|
||||
*last_activity.lock().expect("last_activity poisoned") = Instant::now();
|
||||
// Hold the replay lock across both the buffer push and the
|
||||
// broadcast send so `subscribe_with_replay` either sees this
|
||||
// chunk in its snapshot *or* receives it on the broadcast —
|
||||
|
||||
@@ -1,312 +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
|
||||
//! - a 10s `Heartbeat` ticker.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{DaemonInbound, DaemonOutbound, RpcResult, PROTOCOL_VERSION};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
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);
|
||||
|
||||
#[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,
|
||||
token: String,
|
||||
/// Operator-set perimeter id advertised in heartbeats so peer daemons
|
||||
/// can dial this one over T2 when they share the same perimeter.
|
||||
perimeter_id: Option<String>,
|
||||
/// Daemon's reachable address on the declared perimeter.
|
||||
private_addr: Option<String>,
|
||||
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 {
|
||||
pub fn new(
|
||||
registry_url: impl Into<String>,
|
||||
daemon_id: impl Into<String>,
|
||||
hostname: impl Into<String>,
|
||||
daemon_url: impl Into<String>,
|
||||
token: impl Into<String>,
|
||||
manager: SessionManager,
|
||||
) -> Self {
|
||||
Self::new_with_bus(
|
||||
registry_url,
|
||||
daemon_id,
|
||||
hostname,
|
||||
daemon_url,
|
||||
token,
|
||||
manager,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Variant that wires a [`LocalEventBus`](crate::LocalEventBus) into the
|
||||
/// catalog mirror so peer deltas reach `WS /api/events` (NAP-248).
|
||||
pub fn new_with_bus(
|
||||
registry_url: impl Into<String>,
|
||||
daemon_id: impl Into<String>,
|
||||
hostname: impl Into<String>,
|
||||
daemon_url: 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(),
|
||||
token: token.into(),
|
||||
perimeter_id: None,
|
||||
private_addr: 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. Pass both or neither —
|
||||
/// a private address without a perimeter id is meaningless.
|
||||
pub fn with_perimeter(
|
||||
mut self,
|
||||
perimeter_id: Option<String>,
|
||||
private_addr: Option<String>,
|
||||
) -> Self {
|
||||
self.perimeter_id = perimeter_id;
|
||||
self.private_addr = private_addr;
|
||||
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 {
|
||||
match self.connect_once().await {
|
||||
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(),
|
||||
};
|
||||
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:?}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// Initial snapshot
|
||||
let snapshot = DaemonOutbound::SessionsSnapshot {
|
||||
sessions: self.manager.snapshot().await,
|
||||
};
|
||||
sink.send(Message::Text(serde_json::to_string(&snapshot)?))
|
||||
.await?;
|
||||
|
||||
let mut events = self.manager.events();
|
||||
// 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);
|
||||
|
||||
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).await?;
|
||||
}
|
||||
Some(Ok(_)) => {}
|
||||
}
|
||||
}
|
||||
ev = events.recv() => {
|
||||
match ev {
|
||||
Ok(SessionEvent::Added(s)) => {
|
||||
sink.send(Message::Text(serde_json::to_string(
|
||||
&DaemonOutbound::SessionAdded { session: s },
|
||||
)?)).await?;
|
||||
}
|
||||
Ok(SessionEvent::Removed(id)) => {
|
||||
sink.send(Message::Text(serde_json::to_string(
|
||||
&DaemonOutbound::SessionRemoved { id },
|
||||
)?)).await?;
|
||||
}
|
||||
Ok(SessionEvent::Updated(s)) => {
|
||||
sink.send(Message::Text(serde_json::to_string(
|
||||
&DaemonOutbound::SessionUpdated { session: s },
|
||||
)?)).await?;
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
_ = hb.tick() => {
|
||||
sink.send(Message::Text(serde_json::to_string(
|
||||
&DaemonOutbound::Heartbeat {
|
||||
perimeter_id: self.perimeter_id.clone(),
|
||||
private_addr: self.private_addr.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) -> Result<(), ClientError>
|
||||
where
|
||||
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
|
||||
{
|
||||
let parsed: DaemonInbound = serde_json::from_str(text)?;
|
||||
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) => 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(()) => 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::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;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Store of retained worktrees (#78): worktrees the daemon created whose
|
||||
//! session is gone.
|
||||
//!
|
||||
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
|
||||
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
|
||||
//! missing or malformed files tolerated on read. The store survives a daemon
|
||||
//! restart; it is the recovery path for everything else in the worktree
|
||||
//! lifecycle.
|
||||
//!
|
||||
//! It is not an ownership record: it must never be consulted to decide whether
|
||||
//! a path may be adopted. Its only job is to remember what the daemon is
|
||||
//! holding so it can be listed and reclaimed.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`],
|
||||
/// so the store and the wire payload cannot drift apart.
|
||||
pub use maestro_protocol::RetainedReason;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RetainedStoreError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RetainedWorktreeRecord {
|
||||
pub id: Uuid,
|
||||
pub path: PathBuf,
|
||||
pub base_repo: PathBuf,
|
||||
pub branch: String,
|
||||
pub reason: RetainedReason,
|
||||
pub retained_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetainedStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RetainedStore {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
Self { dir: dir.into() }
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
fn path_for(&self, id: Uuid) -> PathBuf {
|
||||
self.dir.join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
/// Record a retained worktree and return the entry that was written.
|
||||
pub fn retain(
|
||||
&self,
|
||||
path: &Path,
|
||||
base_repo: &Path,
|
||||
branch: &str,
|
||||
reason: RetainedReason,
|
||||
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
|
||||
let entry = RetainedWorktreeRecord {
|
||||
id: Uuid::new_v4(),
|
||||
path: path.to_path_buf(),
|
||||
base_repo: base_repo.to_path_buf(),
|
||||
branch: branch.to_string(),
|
||||
reason,
|
||||
retained_at: Utc::now(),
|
||||
};
|
||||
self.overwrite(&entry)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Atomically write `entry`. Creates the directory if it does not exist.
|
||||
pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let final_path = self.path_for(entry.id);
|
||||
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
|
||||
let json = serde_json::to_vec_pretty(entry)?;
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &final_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
|
||||
/// store already lost (e.g. pruned) must not fail.
|
||||
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
|
||||
match std::fs::remove_file(self.path_for(id)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read every entry. Files that are missing or malformed are skipped
|
||||
/// silently: a corrupt record must not hide the others, and an empty or
|
||||
/// absent directory simply means nothing is retained.
|
||||
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, RetainedStoreError> {
|
||||
let mut out = Vec::new();
|
||||
let entries = match std::fs::read_dir(&self.dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
|
||||
out.push(worktree);
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|w| w.retained_at);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop entries whose worktree directory no longer exists — somebody
|
||||
/// removed it by hand. Returns the ids that were pruned.
|
||||
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
|
||||
let mut pruned = Vec::new();
|
||||
for entry in self.load_all()? {
|
||||
if !entry.path.exists() {
|
||||
self.remove(entry.id)?;
|
||||
pruned.push(entry.id);
|
||||
}
|
||||
}
|
||||
Ok(pruned)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn tmp_dir() -> tempfile::TempDir {
|
||||
tempfile::tempdir().expect("tempdir")
|
||||
}
|
||||
|
||||
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
|
||||
let p = root.join(name);
|
||||
std::fs::create_dir_all(&p).expect("mkdir");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_then_load_round_trips_every_field() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
let repo = sample_path(dir.path(), "repo");
|
||||
|
||||
let entry = store
|
||||
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
|
||||
let all = store.load_all().expect("load_all");
|
||||
assert_eq!(all, vec![entry.clone()]);
|
||||
assert_eq!(entry.path, wt);
|
||||
assert_eq!(entry.base_repo, repo);
|
||||
assert_eq!(entry.branch, "feature/login");
|
||||
assert_eq!(entry.reason, RetainedReason::ForceKilled);
|
||||
let age = Utc::now() - entry.retained_at;
|
||||
assert!(age >= Duration::zero() && age < Duration::minutes(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reason_serializes_kebab_case() {
|
||||
let json =
|
||||
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
|
||||
assert_eq!(json, r#""exited-with-unsaved-work""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_survive_a_restart() {
|
||||
let dir = tmp_dir();
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
|
||||
let first = RetainedStore::new(dir.path());
|
||||
let entry = first
|
||||
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
|
||||
// A fresh store over the same state_dir — what a daemon restart sees.
|
||||
let second = RetainedStore::new(dir.path());
|
||||
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_store_reads_as_empty() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path().join("does-not-exist"));
|
||||
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_entries_are_skipped_silently() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
store
|
||||
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
|
||||
|
||||
let all = store.load_all().expect("load_all");
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].branch, "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let a = store
|
||||
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
let b = store
|
||||
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
assert_ne!(a.id, b.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_the_entry() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
let entry = store
|
||||
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
|
||||
store.remove(entry.id).expect("remove");
|
||||
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
||||
|
||||
// Removing an unknown id is a no-op, not an error.
|
||||
store.remove(entry.id).expect("remove again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_all_is_sorted_by_time() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let mut first = store
|
||||
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
first.retained_at -= Duration::hours(1);
|
||||
store.overwrite(&first).expect("rewrite");
|
||||
|
||||
let second = store
|
||||
.retain(
|
||||
dir.path(),
|
||||
dir.path(),
|
||||
"second",
|
||||
RetainedReason::ForceKilled,
|
||||
)
|
||||
.expect("retain");
|
||||
|
||||
let branches: Vec<_> = store
|
||||
.load_all()
|
||||
.expect("load_all")
|
||||
.into_iter()
|
||||
.map(|e| e.branch)
|
||||
.collect();
|
||||
assert_eq!(branches, vec!["first", "second"]);
|
||||
let _ = second;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_missing_drops_gone_directories_only() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let alive = sample_path(dir.path(), "alive");
|
||||
let gone = sample_path(dir.path(), "gone");
|
||||
let keep = store
|
||||
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
let drop = store
|
||||
.retain(
|
||||
&gone,
|
||||
dir.path(),
|
||||
"drop",
|
||||
RetainedReason::ExitedWithUnsavedWork,
|
||||
)
|
||||
.expect("retain");
|
||||
std::fs::remove_dir_all(&gone).expect("rm");
|
||||
|
||||
let pruned = store.prune_missing().expect("prune_missing");
|
||||
assert_eq!(pruned, vec![drop.id]);
|
||||
|
||||
let branches: Vec<_> = store
|
||||
.load_all()
|
||||
.expect("load_all")
|
||||
.into_iter()
|
||||
.map(|e| e.branch)
|
||||
.collect();
|
||||
assert_eq!(branches, vec!["keep"]);
|
||||
let _ = keep;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
//! Durable per-host spawn history for the spawn form's "recent dirs" picker (#24).
|
||||
//!
|
||||
//! Sidecars are deleted on kill, so they can't back a "recently used working
|
||||
//! directories" list. This module keeps an **append-only JSONL log** at
|
||||
//! `<state_dir>/spawn-history.jsonl`, one [`SpawnHistoryRecord`] per successful
|
||||
//! spawn, that survives kills and daemon restarts.
|
||||
//!
|
||||
//! - **Write:** [`SpawnHistory::append`] adds a line. Best-effort — callers
|
||||
//! ignore the error so a log-write failure never fails a spawn.
|
||||
//! - **Read:** [`SpawnHistory::recent_dirs`] folds the log newest-first, dedups
|
||||
//! by `cwd`, drops entries past the recency cap, and caps the result count.
|
||||
//! - **Prune:** [`SpawnHistory::compact`] rewrites the file dropping records
|
||||
//! past the recency cap; it's called opportunistically from `append` once the
|
||||
//! file grows past [`COMPACT_THRESHOLD_BYTES`].
|
||||
//!
|
||||
//! The recency cap comes from `[daemon].spawn_history_days` (default 30); `0`
|
||||
//! disables history entirely — `append` and `recent_dirs` both become no-ops.
|
||||
//!
|
||||
//! Like [`crate::dirlist`] / [`crate::gitroot`], the time-dependent logic is
|
||||
//! factored into `*_as_of(now)` helpers so the fold/prune behaviour is
|
||||
//! unit-tested deterministically without touching the wall clock.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Max [`RecentDir`] entries [`SpawnHistory::recent_dirs`] returns. The picker
|
||||
/// shows a handful; this bounds the wire payload regardless of log size.
|
||||
pub const DEFAULT_MAX_RESULTS: usize = 50;
|
||||
|
||||
/// File-size threshold past which [`SpawnHistory::append`] triggers an
|
||||
/// opportunistic [`SpawnHistory::compact`]. 256 KiB is thousands of records —
|
||||
/// large enough that compaction is rare, small enough the file never bloats.
|
||||
pub const COMPACT_THRESHOLD_BYTES: u64 = 256 * 1024;
|
||||
|
||||
/// One logged spawn. Serialized as a single JSON line in the JSONL log.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SpawnHistoryRecord {
|
||||
/// The working directory the user chose (the original request `cwd`, not a
|
||||
/// generated worktree checkout path) — what the picker offers to reuse.
|
||||
pub cwd: PathBuf,
|
||||
pub command: String,
|
||||
/// Generated session name, when one was assigned.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Worktree branch, when the spawn requested one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worktree_branch: Option<String>,
|
||||
pub at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A deduplicated recent working directory, newest use first. The query result
|
||||
/// of [`SpawnHistory::recent_dirs`]; #26 maps this to the wire `RecentDir`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RecentDir {
|
||||
pub cwd: PathBuf,
|
||||
/// The command of the most recent spawn in this dir.
|
||||
pub last_command: String,
|
||||
pub last_used: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Map daemon-local recents to the wire payload (#26). `recent_dirs` already
|
||||
/// caps and dedups, so `truncated` is always false here — the field exists for
|
||||
/// wire symmetry with [`maestro_protocol::DirListing`].
|
||||
pub fn to_wire(dirs: Vec<RecentDir>) -> maestro_protocol::RecentDirs {
|
||||
maestro_protocol::RecentDirs {
|
||||
dirs: dirs
|
||||
.into_iter()
|
||||
.map(|d| maestro_protocol::RecentDir {
|
||||
cwd: d.cwd,
|
||||
last_command: d.last_command,
|
||||
last_used: d.last_used,
|
||||
})
|
||||
.collect(),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append-only JSONL spawn log rooted at a file path, with a recency cap.
|
||||
/// Cheap to clone — just the path + caps.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpawnHistory {
|
||||
path: PathBuf,
|
||||
/// Recency cap in days. `0` disables history (every operation is a no-op).
|
||||
retention_days: u32,
|
||||
max_results: usize,
|
||||
compact_threshold_bytes: u64,
|
||||
}
|
||||
|
||||
impl SpawnHistory {
|
||||
/// History logged to `path`, keeping records for `retention_days` (`0`
|
||||
/// disables). Uses [`DEFAULT_MAX_RESULTS`] and [`COMPACT_THRESHOLD_BYTES`].
|
||||
pub fn new(path: impl Into<PathBuf>, retention_days: u32) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
retention_days,
|
||||
max_results: DEFAULT_MAX_RESULTS,
|
||||
compact_threshold_bytes: COMPACT_THRESHOLD_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Whether history is on. `false` when `retention_days == 0`.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.retention_days > 0
|
||||
}
|
||||
|
||||
/// Append one record. No-op when disabled. Best-effort: the caller ignores
|
||||
/// the error so a log-write failure never fails the spawn. Creates the
|
||||
/// parent directory on demand and triggers opportunistic compaction once
|
||||
/// the file grows past [`COMPACT_THRESHOLD_BYTES`].
|
||||
pub fn append(&self, record: &SpawnHistoryRecord) -> std::io::Result<()> {
|
||||
if !self.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut line = serde_json::to_string(record).map_err(std::io::Error::other)?;
|
||||
line.push('\n');
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
f.write_all(line.as_bytes())?;
|
||||
|
||||
// Opportunistic prune: rewrite past the size threshold so the log can't
|
||||
// grow without bound. Best-effort — a compaction failure leaves the
|
||||
// (valid) appended log in place.
|
||||
if f.metadata().map(|m| m.len()).unwrap_or(0) > self.compact_threshold_bytes {
|
||||
if let Err(e) = self.compact() {
|
||||
tracing::warn!(error = %e, "spawn-history compaction failed (non-fatal)");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recent working dirs, newest use first, deduped by `cwd`, within the
|
||||
/// recency cap, capped at `max_results`. Empty when disabled or unreadable.
|
||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||
self.recent_dirs_capped(Utc::now(), self.max_results)
|
||||
}
|
||||
|
||||
/// Every deduped recent dir, newest-first, without the picker's result
|
||||
/// cap (#54): project enrichment folds the whole history, not its first
|
||||
/// page, so a project spawned in long ago still reads as fresh.
|
||||
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
|
||||
self.recent_dirs_capped(Utc::now(), usize::MAX)
|
||||
}
|
||||
|
||||
/// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing.
|
||||
#[cfg(test)]
|
||||
fn recent_dirs_as_of(&self, now: DateTime<Utc>) -> Vec<RecentDir> {
|
||||
self.recent_dirs_capped(now, self.max_results)
|
||||
}
|
||||
|
||||
fn recent_dirs_capped(&self, now: DateTime<Utc>, max_results: usize) -> Vec<RecentDir> {
|
||||
if !self.is_enabled() {
|
||||
return Vec::new();
|
||||
}
|
||||
let cutoff = now - Duration::days(self.retention_days as i64);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
// Records are appended oldest→newest; reverse to fold newest-first so
|
||||
// the first time we see a `cwd` is its most-recent use.
|
||||
for rec in self.read_records().into_iter().rev() {
|
||||
if rec.at < cutoff {
|
||||
continue;
|
||||
}
|
||||
if !seen.insert(rec.cwd.clone()) {
|
||||
continue;
|
||||
}
|
||||
out.push(RecentDir {
|
||||
cwd: rec.cwd,
|
||||
last_command: rec.command,
|
||||
last_used: rec.at,
|
||||
});
|
||||
if out.len() >= max_results {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rewrite the log keeping only records within the recency cap, preserving
|
||||
/// chronological (append) order. No-op when disabled.
|
||||
pub fn compact(&self) -> std::io::Result<()> {
|
||||
self.compact_as_of(Utc::now())
|
||||
}
|
||||
|
||||
/// [`compact`](Self::compact) with an explicit clock for testing.
|
||||
fn compact_as_of(&self, now: DateTime<Utc>) -> std::io::Result<()> {
|
||||
if !self.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let cutoff = now - Duration::days(self.retention_days as i64);
|
||||
let kept: Vec<SpawnHistoryRecord> = self
|
||||
.read_records()
|
||||
.into_iter()
|
||||
.filter(|r| r.at >= cutoff)
|
||||
.collect();
|
||||
let mut body = String::new();
|
||||
for rec in &kept {
|
||||
body.push_str(&serde_json::to_string(rec).map_err(std::io::Error::other)?);
|
||||
body.push('\n');
|
||||
}
|
||||
// Atomic replace (tmp + rename) so a crash mid-rewrite can't truncate
|
||||
// the log, mirroring [`crate::sidecar`]'s write discipline.
|
||||
let tmp = self.path.with_extension("jsonl.tmp");
|
||||
std::fs::write(&tmp, body)?;
|
||||
std::fs::rename(&tmp, &self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse every line of the log into a record, skipping unreadable/malformed
|
||||
/// lines (a partial final write, a hand-edit) rather than failing the read.
|
||||
/// Returns empty when the file is absent.
|
||||
fn read_records(&self) -> Vec<SpawnHistoryRecord> {
|
||||
let contents = match std::fs::read_to_string(&self.path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
contents
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.filter_map(|l| serde_json::from_str::<SpawnHistoryRecord>(l).ok())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rec(cwd: &str, command: &str, at: DateTime<Utc>) -> SpawnHistoryRecord {
|
||||
SpawnHistoryRecord {
|
||||
cwd: PathBuf::from(cwd),
|
||||
command: command.into(),
|
||||
name: None,
|
||||
worktree_branch: None,
|
||||
at,
|
||||
}
|
||||
}
|
||||
|
||||
fn t(s: &str) -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
|
||||
}
|
||||
|
||||
/// `(history, _tempdir)` over a temp state dir. Keep the dir alive.
|
||||
fn fixture(retention_days: u32) -> (SpawnHistory, tempfile::TempDir) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("spawn-history.jsonl");
|
||||
(SpawnHistory::new(path, retention_days), tmp)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_then_read_round_trips_record() {
|
||||
let (h, _tmp) = fixture(30);
|
||||
let r = SpawnHistoryRecord {
|
||||
cwd: PathBuf::from("/home/u/git/maestro"),
|
||||
command: "claude".into(),
|
||||
name: Some("neon-drifter".into()),
|
||||
worktree_branch: Some("feat/x".into()),
|
||||
at: t("2026-06-20T10:00:00Z"),
|
||||
};
|
||||
h.append(&r).unwrap();
|
||||
assert_eq!(h.read_records(), vec![r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_dirs_dedups_by_cwd_newest_first() {
|
||||
let (h, _tmp) = fixture(30);
|
||||
// Same dir spawned twice (older claude, newer zsh) plus a second dir.
|
||||
h.append(&rec("/a", "claude", t("2026-06-20T08:00:00Z")))
|
||||
.unwrap();
|
||||
h.append(&rec("/b", "opencode", t("2026-06-20T09:00:00Z")))
|
||||
.unwrap();
|
||||
h.append(&rec("/a", "zsh", t("2026-06-20T10:00:00Z")))
|
||||
.unwrap();
|
||||
|
||||
let recents = h.recent_dirs_as_of(t("2026-06-20T11:00:00Z"));
|
||||
// /a is most recent (10:00), then /b (09:00); /a appears once with the
|
||||
// newest command.
|
||||
assert_eq!(recents.len(), 2);
|
||||
assert_eq!(recents[0].cwd, PathBuf::from("/a"));
|
||||
assert_eq!(recents[0].last_command, "zsh");
|
||||
assert_eq!(recents[0].last_used, t("2026-06-20T10:00:00Z"));
|
||||
assert_eq!(recents[1].cwd, PathBuf::from("/b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_dirs_drops_entries_past_recency_cap() {
|
||||
let (h, _tmp) = fixture(30);
|
||||
h.append(&rec("/old", "claude", t("2026-01-01T00:00:00Z")))
|
||||
.unwrap();
|
||||
h.append(&rec("/new", "claude", t("2026-06-19T00:00:00Z")))
|
||||
.unwrap();
|
||||
|
||||
// As of 2026-06-20, /old (>30d) is pruned, /new survives.
|
||||
let recents = h.recent_dirs_as_of(t("2026-06-20T00:00:00Z"));
|
||||
let dirs: Vec<&Path> = recents.iter().map(|r| r.cwd.as_path()).collect();
|
||||
assert_eq!(dirs, [Path::new("/new")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_rewrites_dropping_stale_records() {
|
||||
let (h, _tmp) = fixture(30);
|
||||
h.append(&rec("/old", "claude", t("2026-01-01T00:00:00Z")))
|
||||
.unwrap();
|
||||
h.append(&rec("/new", "zsh", t("2026-06-19T00:00:00Z")))
|
||||
.unwrap();
|
||||
assert_eq!(h.read_records().len(), 2);
|
||||
|
||||
h.compact_as_of(t("2026-06-20T00:00:00Z")).unwrap();
|
||||
|
||||
let after = h.read_records();
|
||||
assert_eq!(after.len(), 1);
|
||||
assert_eq!(after[0].cwd, PathBuf::from("/new"));
|
||||
// Order is preserved (chronological), file ends with a newline.
|
||||
let raw = std::fs::read_to_string(h.path()).unwrap();
|
||||
assert!(raw.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_history_is_a_no_op() {
|
||||
let (h, _tmp) = fixture(0);
|
||||
assert!(!h.is_enabled());
|
||||
h.append(&rec("/a", "claude", t("2026-06-20T10:00:00Z")))
|
||||
.unwrap();
|
||||
// Nothing written, nothing read.
|
||||
assert!(!h.path().exists());
|
||||
assert!(h.recent_dirs_as_of(t("2026-06-20T11:00:00Z")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_skips_malformed_lines() {
|
||||
let (h, _tmp) = fixture(30);
|
||||
h.append(&rec("/a", "claude", t("2026-06-20T10:00:00Z")))
|
||||
.unwrap();
|
||||
// Append a junk line + a blank line by hand.
|
||||
std::fs::write(
|
||||
h.path(),
|
||||
format!(
|
||||
"{{\"not\":\"a record\"}}\n\n{}\n",
|
||||
serde_json::to_string(&rec("/a", "claude", t("2026-06-20T10:00:00Z"))).unwrap()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// Only the one valid record is parsed.
|
||||
assert_eq!(h.read_records().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_dirs_caps_result_count() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30);
|
||||
h.max_results = 2;
|
||||
for i in 0..5 {
|
||||
h.append(&rec(
|
||||
&format!("/d{i}"),
|
||||
"claude",
|
||||
t("2026-06-20T10:00:00Z") + Duration::minutes(i),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let recents = h.recent_dirs_as_of(t("2026-06-20T11:00:00Z"));
|
||||
assert_eq!(recents.len(), 2);
|
||||
// Newest two dirs: /d4 then /d3.
|
||||
assert_eq!(recents[0].cwd, PathBuf::from("/d4"));
|
||||
assert_eq!(recents[1].cwd, PathBuf::from("/d3"));
|
||||
}
|
||||
|
||||
/// Project enrichment (#54) folds the whole log, not just the picker's
|
||||
/// first page.
|
||||
#[test]
|
||||
fn all_recent_dirs_skips_the_result_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30);
|
||||
h.max_results = 2;
|
||||
// Recent enough to survive the 30-day cap against the real clock.
|
||||
let base = Utc::now() - Duration::hours(1);
|
||||
for i in 0..5 {
|
||||
h.append(&rec(
|
||||
&format!("/d{i}"),
|
||||
"claude",
|
||||
base + Duration::minutes(i),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(h.recent_dirs().len(), 2);
|
||||
let all = h.all_recent_dirs();
|
||||
assert_eq!(all.len(), 5);
|
||||
assert_eq!(all[0].cwd, PathBuf::from("/d4"));
|
||||
assert_eq!(all[4].cwd, PathBuf::from("/d0"));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! 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).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -74,13 +76,19 @@ impl Tmux {
|
||||
if let Some(d) = &self.tmpdir {
|
||||
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
|
||||
}
|
||||
|
||||
/// `tmux set-option -g default-terminal tmux-256color \; new-session -d -e TERM=xterm-256color -e COLORTERM=truecolor -s maestro-<id> -c <cwd> <command>`.
|
||||
///
|
||||
/// `command` is passed as a single shell-line argument to tmux, which
|
||||
/// invokes it via `$SHELL -c`. Two layers ensure attach clients see color:
|
||||
/// `command` is wrapped by [`login_shell_command`] (so the spawned process
|
||||
/// inherits the user's rc-configured PATH) and passed as a single shell-line
|
||||
/// argument to tmux, which invokes it via `$SHELL -c`. Two layers ensure
|
||||
/// attach clients see color:
|
||||
/// the `-e TERM=…`/`COLORTERM=…` flags populate the spawned shell's env
|
||||
/// even when `maestrod` runs from a stripped systemd unit, and the chained
|
||||
/// `set-option -g default-terminal tmux-256color` overrides tmux's
|
||||
@@ -90,9 +98,45 @@ impl Tmux {
|
||||
/// default `exit-empty on`.
|
||||
pub fn new_session(&self, id: Uuid, cwd: &Path, command: &str) -> Result<(), TmuxError> {
|
||||
let name = session_name(id);
|
||||
let command = login_shell_command(env::var_os("SHELL").as_deref(), command);
|
||||
let out = self
|
||||
.cmd()
|
||||
.args(new_session_args(&name, cwd, command))
|
||||
.args(new_session_args(
|
||||
&name,
|
||||
cwd,
|
||||
&command,
|
||||
&BTreeMap::new(),
|
||||
None,
|
||||
))
|
||||
.output()?;
|
||||
check(out)
|
||||
}
|
||||
|
||||
/// Like [`Self::new_session`], but chains `pipe-pane -t maestro-<id>
|
||||
/// <sink_command>` into the same tmux invocation. Use this from
|
||||
/// `SessionManager::create` so the pipe is in place before the freshly
|
||||
/// `exec`-ed child writes its first byte; calling `new_session` + a
|
||||
/// separate `pipe_pane` opens a window in which initial output is
|
||||
/// silently dropped (NAP-253).
|
||||
pub fn new_session_piped(
|
||||
&self,
|
||||
id: Uuid,
|
||||
cwd: &Path,
|
||||
command: &str,
|
||||
session_env: &BTreeMap<String, String>,
|
||||
sink_command: &str,
|
||||
) -> Result<(), TmuxError> {
|
||||
let name = session_name(id);
|
||||
let command = login_shell_command(env::var_os("SHELL").as_deref(), command);
|
||||
let out = self
|
||||
.cmd()
|
||||
.args(new_session_args(
|
||||
&name,
|
||||
cwd,
|
||||
&command,
|
||||
session_env,
|
||||
Some(sink_command),
|
||||
))
|
||||
.output()?;
|
||||
check(out)
|
||||
}
|
||||
@@ -134,6 +178,43 @@ impl Tmux {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// `tmux list-panes -t maestro-<id> -F '#{pane_pid} #{pane_current_command}
|
||||
/// #{pane_tty} #{pane_current_path}'` for the session's (single) pane. Used
|
||||
/// by the activity watcher (NAP-340) to learn the foreground command, the
|
||||
/// pane's process, its tty, and its *current* working directory. The path is
|
||||
/// the last field, so [`parse_pane_line`] takes the first three
|
||||
/// space-separated tokens and keeps the remainder verbatim — a cwd that
|
||||
/// itself contains spaces survives intact. Returns `Ok(None)` when the
|
||||
/// session/pane is gone or tmux isn't running, so the watcher degrades to
|
||||
/// "unknown" rather than erroring.
|
||||
pub fn pane_info(&self, id: Uuid) -> Result<Option<PaneInfo>, TmuxError> {
|
||||
let name = session_name(id);
|
||||
let out = self
|
||||
.cmd()
|
||||
.args([
|
||||
"list-panes",
|
||||
"-t",
|
||||
&name,
|
||||
"-F",
|
||||
"#{pane_pid} #{pane_current_command} #{pane_tty} #{pane_current_path}",
|
||||
])
|
||||
.output()?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
// No server, or the session/pane vanished between the liveness check
|
||||
// and now — both are "no info", not a hard error.
|
||||
if is_no_server_error(&stderr) || stderr.contains("can't find") {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(TmuxError::NonZero {
|
||||
code: out.status.code().unwrap_or(-1),
|
||||
stderr: stderr.into_owned(),
|
||||
});
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
Ok(stdout.lines().next().and_then(parse_pane_line))
|
||||
}
|
||||
|
||||
/// `tmux send-keys -t maestro-<id> -l <bytes>`. The `-l` flag tells tmux to
|
||||
/// treat the payload as literal input (no key-name translation), which is
|
||||
/// what we want for forwarded PTY input.
|
||||
@@ -149,16 +230,18 @@ impl Tmux {
|
||||
check(out)
|
||||
}
|
||||
|
||||
/// `tmux pipe-pane -t maestro-<id> -o '<sink_command>'`.
|
||||
/// `tmux pipe-pane -t maestro-<id> '<sink_command>'`.
|
||||
///
|
||||
/// `sink_command` is a shell command that receives pane output on stdin
|
||||
/// (e.g. `cat >> /tmp/sess.log`). The `-o` flag makes the call idempotent:
|
||||
/// if a pipe is already running on the pane, this is a no-op.
|
||||
/// (e.g. `cat >> /tmp/sess.log`). Without `-o`, tmux closes any pipe
|
||||
/// already running on the pane before opening this one — important on
|
||||
/// `SessionManager::recover`, where the previous daemon process may have
|
||||
/// left a `pipe-pane` writing to a FIFO that no one is reading anymore.
|
||||
pub fn pipe_pane(&self, id: Uuid, sink_command: &str) -> Result<(), TmuxError> {
|
||||
let name = session_name(id);
|
||||
let out = self
|
||||
.cmd()
|
||||
.args(["pipe-pane", "-t", &name, "-o", sink_command])
|
||||
.args(["pipe-pane", "-t", &name, sink_command])
|
||||
.output()?;
|
||||
check(out)
|
||||
}
|
||||
@@ -198,23 +281,117 @@ 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
|
||||
/// socket" (multiple phrasings across versions).
|
||||
fn is_no_server_error(stderr: &str) -> bool {
|
||||
stderr.contains("no server running")
|
||||
|| stderr.contains("no sessions")
|
||||
|| stderr.contains("error connecting")
|
||||
NO_SERVER_PHRASES.iter().any(|p| stderr.contains(p))
|
||||
}
|
||||
|
||||
/// A session pane's process id, foreground command, controlling tty, and
|
||||
/// current working directory, as reported by `tmux list-panes` (NAP-340).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PaneInfo {
|
||||
pub pane_pid: i32,
|
||||
pub command: String,
|
||||
pub tty: String,
|
||||
/// `#{pane_current_path}` — the pane's live cwd. Empty if tmux reported none.
|
||||
pub current_path: String,
|
||||
}
|
||||
|
||||
/// Parse one `#{pane_pid} #{pane_current_command} #{pane_tty} #{pane_current_path}`
|
||||
/// line. `pane_pid`, `pane_current_command`, and `pane_tty` are single tokens, so
|
||||
/// we split on the first three spaces and treat the remainder as the path —
|
||||
/// which keeps a cwd intact even when it contains spaces. The path field is
|
||||
/// absent on older format strings, in which case it defaults to empty.
|
||||
fn parse_pane_line(line: &str) -> Option<PaneInfo> {
|
||||
let mut parts = line.splitn(4, ' ');
|
||||
let pane_pid: i32 = parts.next()?.trim().parse().ok()?;
|
||||
let command = parts.next()?;
|
||||
let tty = parts.next()?;
|
||||
let current_path = parts.next().unwrap_or("");
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(PaneInfo {
|
||||
pane_pid,
|
||||
command: command.to_string(),
|
||||
tty: tty.to_string(),
|
||||
current_path: current_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn session_name(id: Uuid) -> String {
|
||||
format!("{SESSION_PREFIX}{id}")
|
||||
}
|
||||
|
||||
/// Args passed to `tmux` for `new_session`, excluding the binary itself and
|
||||
/// any socket/tmpdir flags injected by [`Tmux::cmd`]. Split out so the argv
|
||||
/// can be asserted without invoking tmux.
|
||||
fn new_session_args(name: &str, cwd: &Path, command: &str) -> Vec<OsString> {
|
||||
vec![
|
||||
/// Wrap a session command so it runs through the user's login + interactive
|
||||
/// shell.
|
||||
///
|
||||
/// `maestrod` usually runs from a stripped systemd user unit whose `PATH`
|
||||
/// lacks per-user toolchain dirs (`~/.local/bin`, `~/.opencode/bin`, …). tmux
|
||||
/// runs a new-session command via `$SHELL -c` *non-interactively*, so a bare
|
||||
/// command like `claude` or `opencode` — which lives only on the PATH the user
|
||||
/// exports from `~/.zshrc` / `~/.bashrc` — fails with "command not found".
|
||||
/// Re-execing an interactive login shell sources that rc first, restoring the
|
||||
/// PATH the user sees in a normal terminal. `exec` keeps the process tree flat
|
||||
/// so pane-command inference still sees the real child.
|
||||
///
|
||||
/// Falls back to the bare command when `$SHELL` is unset/empty (nothing better
|
||||
/// to wrap with).
|
||||
fn login_shell_command(shell: Option<&OsStr>, command: &str) -> String {
|
||||
match shell {
|
||||
Some(shell) if !shell.is_empty() => {
|
||||
format!(
|
||||
"exec {} -lic {}",
|
||||
shell.to_string_lossy(),
|
||||
single_quote(command)
|
||||
)
|
||||
}
|
||||
_ => command.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POSIX single-quote a string: wrap in `'…'`, rewriting embedded `'` as
|
||||
/// `'\''`. Safe to splice into a shell command line.
|
||||
fn single_quote(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('\'');
|
||||
for ch in s.chars() {
|
||||
if ch == '\'' {
|
||||
out.push_str("'\\''");
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out.push('\'');
|
||||
out
|
||||
}
|
||||
|
||||
/// Args passed to `tmux` for `new_session` / `new_session_piped`, excluding
|
||||
/// the binary itself and any socket/tmpdir flags injected by [`Tmux::cmd`].
|
||||
/// When `pipe_sink` is `Some`, a chained `pipe-pane -t <name> <sink>` is
|
||||
/// appended so the pipe attaches in the same tmux invocation that creates
|
||||
/// the session.
|
||||
fn new_session_args(
|
||||
name: &str,
|
||||
cwd: &Path,
|
||||
command: &str,
|
||||
env: &BTreeMap<String, String>,
|
||||
pipe_sink: Option<&str>,
|
||||
) -> Vec<OsString> {
|
||||
let mut args = vec![
|
||||
OsString::from("set-option"),
|
||||
OsString::from("-g"),
|
||||
OsString::from("default-terminal"),
|
||||
@@ -230,8 +407,24 @@ fn new_session_args(name: &str, cwd: &Path, command: &str) -> Vec<OsString> {
|
||||
OsString::from(name),
|
||||
OsString::from("-c"),
|
||||
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 {
|
||||
args.extend([
|
||||
OsString::from(";"),
|
||||
OsString::from("pipe-pane"),
|
||||
OsString::from("-t"),
|
||||
OsString::from(name),
|
||||
OsString::from(sink),
|
||||
]);
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn check(out: Output) -> Result<(), TmuxError> {
|
||||
@@ -249,9 +442,122 @@ fn check(out: Output) -> Result<(), TmuxError> {
|
||||
mod tests {
|
||||
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]
|
||||
fn parse_pane_line_splits_pid_command_tty_path() {
|
||||
let p = parse_pane_line("12345 claude /dev/pts/3 /home/naps62/maestro").unwrap();
|
||||
assert_eq!(p.pane_pid, 12345);
|
||||
assert_eq!(p.command, "claude");
|
||||
assert_eq!(p.tty, "/dev/pts/3");
|
||||
assert_eq!(p.current_path, "/home/naps62/maestro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pane_line_keeps_spaces_in_path() {
|
||||
// The path is the trailing field (split on the first three spaces only),
|
||||
// so spaces inside it survive intact.
|
||||
let p = parse_pane_line("9 bash /dev/pts/0 /home/naps62/My Projects/x").unwrap();
|
||||
assert_eq!(p.current_path, "/home/naps62/My Projects/x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pane_line_tolerates_missing_path_field() {
|
||||
// A daemon/tmux that omits the path field still yields the other fields.
|
||||
let p = parse_pane_line("12345 claude /dev/pts/3").unwrap();
|
||||
assert_eq!(p.command, "claude");
|
||||
assert_eq!(p.current_path, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pane_line_rejects_malformed() {
|
||||
assert!(parse_pane_line("").is_none());
|
||||
assert!(parse_pane_line("notapid bash /dev/pts/0").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_args_pass_256color_env_and_default_terminal() {
|
||||
let args = new_session_args("maestro-abc", Path::new("/tmp/proj"), "sleep 30");
|
||||
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();
|
||||
assert_eq!(
|
||||
strs,
|
||||
@@ -275,4 +581,48 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_shell_command_wraps_in_interactive_login_shell() {
|
||||
assert_eq!(
|
||||
login_shell_command(Some(OsStr::new("/usr/bin/zsh")), "claude"),
|
||||
"exec /usr/bin/zsh -lic 'claude'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_shell_command_quotes_command_with_spaces_and_quotes() {
|
||||
assert_eq!(
|
||||
login_shell_command(Some(OsStr::new("/bin/bash")), "sh -c 'echo hi'"),
|
||||
r#"exec /bin/bash -lic 'sh -c '\''echo hi'\'''"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_shell_command_passes_through_without_shell() {
|
||||
assert_eq!(login_shell_command(None, "claude"), "claude");
|
||||
assert_eq!(
|
||||
login_shell_command(Some(OsStr::new("")), "claude"),
|
||||
"claude"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_args_chain_pipe_pane_when_sink_present() {
|
||||
let args = new_session_args(
|
||||
"maestro-abc",
|
||||
Path::new("/tmp/proj"),
|
||||
"sleep 30",
|
||||
&BTreeMap::new(),
|
||||
Some("cat >> '/tmp/fifo'"),
|
||||
);
|
||||
let strs: Vec<&str> = args.iter().map(|s| s.to_str().unwrap()).collect();
|
||||
// The pipe-pane chain must come after new-session in the same argv
|
||||
// so tmux attaches the pipe before the child writes its first byte
|
||||
// (NAP-253).
|
||||
assert_eq!(
|
||||
&strs[strs.len() - 5..],
|
||||
&[";", "pipe-pane", "-t", "maestro-abc", "cat >> '/tmp/fifo'"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,79 +4,270 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorktreeError {
|
||||
#[error("failed to spawn git: {0}")]
|
||||
Spawn(#[from] std::io::Error),
|
||||
#[error("git exited with code {code}: {stderr}")]
|
||||
NonZero { code: i32, stderr: String },
|
||||
/// A registered worktree lives under the tree about to be removed; git
|
||||
/// hides its contents from `git status`, so removal would delete its
|
||||
/// uncommitted work unseen (#54 review).
|
||||
#[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
|
||||
/// `base_repo`. Each call appends a UUID-suffixed directory so concurrent
|
||||
/// `add()`s never collide.
|
||||
impl From<crate::worktree_probe::ProbeError> for WorktreeError {
|
||||
fn from(e: crate::worktree_probe::ProbeError) -> Self {
|
||||
match e {
|
||||
crate::worktree_probe::ProbeError::Spawn(io) => WorktreeError::Spawn(io),
|
||||
crate::worktree_probe::ProbeError::NonZero { code, stderr } => {
|
||||
WorktreeError::NonZero { code, stderr }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
|
||||
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
|
||||
/// are findable from the repo; an operator-set root overrides that.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorktreeManager {
|
||||
root: PathBuf,
|
||||
/// `[daemon].worktree_root` override. `None` uses the default
|
||||
/// `<repo>/worktrees/<branch>` placement.
|
||||
root_override: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl WorktreeManager {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
pub fn new(root_override: Option<PathBuf>) -> Self {
|
||||
Self { root_override }
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
pub fn root(&self) -> Option<&Path> {
|
||||
self.root_override.as_deref()
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree add -b <branch> <generated-path>`.
|
||||
/// Returns the path of the new worktree on success.
|
||||
pub fn add(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
||||
std::fs::create_dir_all(&self.root)?;
|
||||
let path = self.generate_path(branch);
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
.args(["worktree", "add", "-b", branch])
|
||||
.arg(&path)
|
||||
.output()?;
|
||||
/// Where a worktree for `branch` will be created: the override root, or
|
||||
/// `<main checkout>/worktrees/<sanitized branch>` by default. If the path
|
||||
/// is occupied, [`Self::add_new`] / [`Self::add_existing`] sidestep to
|
||||
/// `<path>-2`, `<path>-3`, ... instead.
|
||||
///
|
||||
/// `base_repo` is whatever the client picked, which the project list makes
|
||||
/// easy to point at a *linked worktree* — placing the new tree inside it
|
||||
/// would nest one worktree in another, and git hides a registered
|
||||
/// worktree's contents from the outer `git status`, so the outer tree
|
||||
/// would later look clean and its removal would delete the inner one's
|
||||
/// uncommitted work (#54 review). Always place against the main checkout.
|
||||
///
|
||||
/// The override root gets the repo's basename too: without it two repos
|
||||
/// spawning the same branch name target one directory.
|
||||
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
|
||||
// An unusable branch name falls back to a digest rather than escaping
|
||||
// the root; `run_add` surfaces git's own error for it anyway.
|
||||
let slug = branch_relpath(branch).unwrap_or_else(|| PathBuf::from(short_hash(branch)));
|
||||
let main = main_checkout(base_repo);
|
||||
match &self.root_override {
|
||||
Some(root) => {
|
||||
// Basename alone collides for two checkouts of the same name
|
||||
// (`~/a/maestro` and `~/b/maestro`), so disambiguate with a
|
||||
// digest of the full path (#54 review).
|
||||
let base = main
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "repo".into());
|
||||
let repo = format!("{base}-{}", short_hash(&main.to_string_lossy()));
|
||||
root.join(repo).join(slug)
|
||||
}
|
||||
None => main.join("worktrees").join(slug),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::target_path`] with `-n` appended to the final component, the
|
||||
/// collision suffix (#77).
|
||||
fn target_path_suffixed(&self, base_repo: &Path, branch: &str, n: u32) -> PathBuf {
|
||||
let mut name = self.target_path(base_repo, branch).into_os_string();
|
||||
name.push(format!("-{n}"));
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
// Clear registrations whose directory is gone first, so a
|
||||
// previously-leaked entry doesn't push this branch to a suffix.
|
||||
prune(base_repo)?;
|
||||
// Anything occupying the target — a tree we left behind, one the
|
||||
// operator made, an unrelated directory — is sidestepped with a
|
||||
// numeric suffix on the directory; the branch stays whatever was
|
||||
// requested (#77). The daemon never adopts what it finds.
|
||||
let mut path = self.target_path(base_repo, branch);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() {
|
||||
n += 1;
|
||||
path = self.target_path_suffixed(base_repo, branch, n);
|
||||
}
|
||||
// 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)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree remove --force <path>`.
|
||||
/// `git -C <base_repo> worktree remove <path>`.
|
||||
///
|
||||
/// Unconditional (#79): the guard moved off the worktree and onto the
|
||||
/// kill, so by the time removal runs the caller has already chosen to
|
||||
/// destroy the tree — the kill only gets here after
|
||||
/// [`crate::worktree_probe::in_the_way`] came back clear. Still refused for a tree holding a
|
||||
/// nested registered worktree, which `git status` cannot see.
|
||||
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
||||
// Gone from disk already (operator `rm -rf`, or a create that failed
|
||||
// half-way). Probing it with git would fail and be misread as unsaved
|
||||
// work, stranding the registration — and since paths are
|
||||
// deterministic, `worktree add` would then refuse that branch forever
|
||||
// with "missing but already registered" (#56 review).
|
||||
if !path.exists() {
|
||||
return prune(base_repo);
|
||||
}
|
||||
if let Some(inner) = nested_worktree(path) {
|
||||
return Err(WorktreeError::Guarded {
|
||||
path: path.to_path_buf(),
|
||||
reason: format!("holds a nested worktree at {}", inner.display()),
|
||||
});
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
.args(["worktree", "remove", "--force"])
|
||||
.args(["worktree", "remove"])
|
||||
.arg(path)
|
||||
.output()?;
|
||||
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 {
|
||||
let mut out = String::with_capacity(branch.len());
|
||||
for c in branch.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
out.push(c);
|
||||
} else if c == '/' || c == '.' {
|
||||
out.push('-');
|
||||
/// The repository's main checkout. `--git-common-dir` points at the shared
|
||||
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
||||
/// back to the input when git can't answer.
|
||||
fn main_checkout(path: &Path) -> PathBuf {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
||||
.output();
|
||||
let Ok(out) = out else {
|
||||
return path.to_path_buf();
|
||||
};
|
||||
if !out.status.success() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
let common = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim().to_string());
|
||||
common
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| path.to_path_buf())
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push_str("wt");
|
||||
|
||||
/// `Some(inner)` when a registered worktree lives under `path`. Git omits
|
||||
/// those directories from `git status`, so the dirty-work guard cannot see
|
||||
/// them and removal would delete them (#54 review).
|
||||
fn nested_worktree(path: &Path) -> Option<PathBuf> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.output()
|
||||
.ok()?;
|
||||
// `worktree list` prints fully-resolved paths while ours comes from
|
||||
// `--git-common-dir` and may still hold symlinks. Comparing the two forms
|
||||
// directly makes the guard silently pass on any symlinked repo path — and
|
||||
// this guard is what stands between a kill and deleting an inner
|
||||
// worktree's uncommitted work (#54 review).
|
||||
let needle = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines()
|
||||
.filter_map(|l| l.strip_prefix("worktree "))
|
||||
.map(PathBuf::from)
|
||||
.find(|p| {
|
||||
let real = p.canonicalize().unwrap_or_else(|_| p.clone());
|
||||
real != needle && real.starts_with(&needle)
|
||||
})
|
||||
}
|
||||
out
|
||||
|
||||
/// Short digest used to disambiguate override roots.
|
||||
///
|
||||
/// Hand-rolled FNV-1a rather than `DefaultHasher`: this value becomes a
|
||||
/// directory name, and `DefaultHasher`'s algorithm is explicitly not
|
||||
/// guaranteed stable across Rust releases — a toolchain bump would remap every
|
||||
/// repo to a new directory and orphan the worktrees under the old one
|
||||
/// (#67 review).
|
||||
fn short_hash(s: &str) -> String {
|
||||
let mut h: u32 = 0x811c_9dc5;
|
||||
for b in s.as_bytes() {
|
||||
h ^= u32::from(*b);
|
||||
h = h.wrapping_mul(0x0100_0193);
|
||||
}
|
||||
format!("{h:08x}")
|
||||
}
|
||||
|
||||
/// The relative directory a branch's worktree lives in.
|
||||
///
|
||||
/// A git branch name is already a valid relative path — git stores it as
|
||||
/// `.git/refs/heads/feat/example` — so it is used verbatim and `feat/example`
|
||||
/// nests under `worktrees/feat/`. That keeps the path readable *and* keeps the
|
||||
/// mapping injective: flattening `/` and `.` to `-` made `feat/x`, `feat.x`
|
||||
/// and `feat-x` share one directory (#54 review).
|
||||
///
|
||||
/// Git already forbids holding both `feat` and `feat/x`, so the directory
|
||||
/// collision that nesting could cause cannot arise from valid refs. Only
|
||||
/// traversal and absolute components are rejected.
|
||||
fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let candidate = Path::new(branch);
|
||||
if branch.is_empty()
|
||||
|| !candidate
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(candidate.to_path_buf())
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||
/// no longer exists.
|
||||
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
.args(["worktree", "prune"])
|
||||
.output()?;
|
||||
check(out)
|
||||
}
|
||||
|
||||
fn check(out: Output) -> Result<(), WorktreeError> {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! The one git probe for what a worktree holds that would be lost, plus
|
||||
//! guard-free reclaim (#79, #80).
|
||||
//!
|
||||
//! [`in_the_way`] is the single predicate behind all three paths: the kill
|
||||
//! refusal (#79), the natural-exit classifier (#82), and the retained list
|
||||
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
|
||||
//! answer the question differently — #79 and #80 were built in parallel and
|
||||
//! each grew its own copy, which is exactly the drift this collapses.
|
||||
//!
|
||||
//! Reclaim never consults it: reclaim is the deliberate destructive act, and
|
||||
//! the operator chose it while looking at what was in the way.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use maestro_protocol::InTheWay;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProbeError {
|
||||
#[error("failed to spawn git: {0}")]
|
||||
Spawn(#[from] std::io::Error),
|
||||
#[error("git exited with code {code}: {stderr}")]
|
||||
NonZero { code: i32, stderr: String },
|
||||
}
|
||||
|
||||
/// What a kill refusal would report about the worktree at `path`, right now.
|
||||
///
|
||||
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
|
||||
/// `target/`, and counting those would put every tree permanently in the way.
|
||||
/// A directory that no longer exists reports nothing in the way: there is
|
||||
/// nothing left to lose, and the store's own pruning drops the entry.
|
||||
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
||||
let mut breakdown = InTheWay::default();
|
||||
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
|
||||
// our purposes, and following it would probe the wrong tree.
|
||||
if std::fs::symlink_metadata(path).is_err() {
|
||||
return Ok(breakdown);
|
||||
}
|
||||
let status = run_git(
|
||||
path,
|
||||
[
|
||||
"status",
|
||||
"--porcelain",
|
||||
"--ignored=no",
|
||||
"--untracked-files=normal",
|
||||
],
|
||||
)?;
|
||||
for line in String::from_utf8_lossy(&status.stdout).lines() {
|
||||
if line.starts_with("??") {
|
||||
breakdown.untracked_files += 1;
|
||||
} else if !line.is_empty() {
|
||||
breakdown.uncommitted_files += 1;
|
||||
}
|
||||
}
|
||||
// Resolved inside the worktree so a detached HEAD still has a starting
|
||||
// point; asking the base repo for `refs/heads/<branch>` fails there.
|
||||
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
|
||||
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
|
||||
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
|
||||
// Commits reachable from the tip but from no remote-tracking ref. A branch
|
||||
// pushed anywhere counts as pushed; there is no "which remote" question to
|
||||
// get wrong.
|
||||
let unpushed = run_git(path, ["rev-list", "--count", tip, "--not", "--remotes"])?;
|
||||
breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
Ok(breakdown)
|
||||
}
|
||||
|
||||
/// Remove a retained worktree's directory without consulting any guard, per
|
||||
/// the reclaim contract: the operator chose this while looking at what was in
|
||||
/// the way. `git worktree remove` keeps `refs/heads/<branch>`, so commits on
|
||||
/// the branch survive even when they were never pushed.
|
||||
///
|
||||
/// A directory that is already gone only leaves a stale registration behind;
|
||||
/// `worktree prune` clears it so the branch can be spawned again.
|
||||
pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> {
|
||||
if !path.exists() {
|
||||
return run_git(base_repo, ["worktree", "prune"]).map(|_| ());
|
||||
}
|
||||
run_git(
|
||||
base_repo,
|
||||
[
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
path.to_str().expect("worktree path is valid utf-8"),
|
||||
],
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Branch checked out at `path`. Sessions adopted from a sidecar carry no
|
||||
/// branch on record, so a forced kill (#81) asks the tree itself when it
|
||||
/// needs one for the retained store. `None` when git cannot answer.
|
||||
pub fn current_branch(path: &Path) -> Option<String> {
|
||||
let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.arg("-C").arg(dir).args(args);
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
return Err(ProbeError::NonZero {
|
||||
code: out.status.code().unwrap_or(-1),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
struct Repo {
|
||||
root: tempfile::TempDir,
|
||||
repo: PathBuf,
|
||||
}
|
||||
|
||||
fn git(dir: &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)
|
||||
);
|
||||
}
|
||||
|
||||
/// A repo with a remote, one commit on `main`, and a linked worktree for
|
||||
/// branch `feature` at `<repo>/worktrees/feature`.
|
||||
fn repo_with_worktree() -> Repo {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let repo = root.path().join("repo");
|
||||
let remote = root.path().join("remote.git");
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
std::fs::create_dir_all(&remote).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
git(&repo, &["config", "user.email", "t@t"]);
|
||||
git(&repo, &["config", "user.name", "T"]);
|
||||
std::fs::write(repo.join("README"), b"hello\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
git(&remote, &["init", "-q", "--bare"]);
|
||||
git(&repo, &["remote", "add", "origin", &path_str(&remote)]);
|
||||
git(&repo, &["push", "-q", "-u", "origin", "main"]);
|
||||
git(
|
||||
&repo,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"feature",
|
||||
"worktrees/feature",
|
||||
],
|
||||
);
|
||||
Repo { root, repo }
|
||||
}
|
||||
|
||||
fn path_str(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
fn wt(&self) -> PathBuf {
|
||||
self.repo.join("worktrees").join("feature")
|
||||
}
|
||||
|
||||
fn commit_in_wt(&self, msg: &str) {
|
||||
std::fs::write(self.wt().join("README"), msg.as_bytes()).unwrap();
|
||||
git(&self.wt(), &["add", "-A"]);
|
||||
git(&self.wt(), &["commit", "-qm", msg]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_pushed_worktree_has_nothing_in_the_way() {
|
||||
let r = repo_with_worktree();
|
||||
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown, InTheWay::default());
|
||||
assert!(breakdown.is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncommitted_untracked_and_unpushed_are_counted_separately() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||||
std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap();
|
||||
|
||||
let dirty = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(dirty.uncommitted_files, 1);
|
||||
assert_eq!(dirty.untracked_files, 1);
|
||||
assert_eq!(dirty.unpushed_commits, 0);
|
||||
assert!(!dirty.is_clear());
|
||||
|
||||
// Committing clears exactly one dimension and creates another.
|
||||
git(&r.wt(), &["add", "README"]);
|
||||
git(&r.wt(), &["commit", "-qm", "wip"]);
|
||||
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown.uncommitted_files, 0);
|
||||
assert_eq!(breakdown.untracked_files, 1);
|
||||
assert_eq!(breakdown.unpushed_commits, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_edits_count_as_uncommitted() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
assert_eq!(breakdown.untracked_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_clears_unpushed_but_keeps_other_entries() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap();
|
||||
r.commit_in_wt("wip");
|
||||
|
||||
let before = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(before.unpushed_commits, 1);
|
||||
|
||||
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||||
// Same stored entry, different tree: the breakdown must move with it.
|
||||
let after = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(after.unpushed_commits, 0);
|
||||
assert_eq!(after.untracked_files, 0);
|
||||
|
||||
std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap();
|
||||
let dirty = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(dirty.untracked_files, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_files_never_count() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join(".gitignore"), b"target/\n").unwrap();
|
||||
std::fs::create_dir_all(r.wt().join("target")).unwrap();
|
||||
std::fs::write(r.wt().join("target/artifact"), b"x").unwrap();
|
||||
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(
|
||||
breakdown,
|
||||
InTheWay {
|
||||
untracked_files: 1,
|
||||
..InTheWay::default()
|
||||
},
|
||||
"the .gitignore itself is untracked; target/ is not"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_directory_reports_nothing_in_the_way() {
|
||||
let r = repo_with_worktree();
|
||||
let gone = r.root.path().join("nowhere");
|
||||
let breakdown = in_the_way(&gone).expect("probe");
|
||||
assert!(breakdown.is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_removes_the_directory_and_prunes_a_gone_one() {
|
||||
let r = repo_with_worktree();
|
||||
r.commit_in_wt("precious");
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
||||
assert!(!r.wt().exists());
|
||||
|
||||
// A second reclaim over the now-missing path just prunes.
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_keeps_the_branch_and_its_commits() {
|
||||
let r = repo_with_worktree();
|
||||
r.commit_in_wt("precious");
|
||||
let sha = {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(r.wt())
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
||||
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&r.repo)
|
||||
.args(["rev-parse", "--verify", "refs/heads/feature"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"branch must survive reclaim: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_is_guard_free_on_a_dirty_tree() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap();
|
||||
std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap();
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard");
|
||||
assert!(!r.wt().exists());
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
//! - bad / good JWT
|
||||
//! - unknown session is rejected
|
||||
//! - PTY bytes round-trip through real tmux
|
||||
//! - multi-attach: two clients see the same output
|
||||
|
||||
@@ -9,11 +9,10 @@ use std::collections::HashMap;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use common::DaemonFixture;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_daemon::{attach, issue_jwt, JwtVerifier, PairAttachVerifier, SessionManager};
|
||||
use maestro_protocol::{CreateSessionRequest, JwtClaims};
|
||||
use maestro_daemon::{local_api, SessionManager};
|
||||
use maestro_protocol::CreateSessionRequest;
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
@@ -21,8 +20,6 @@ use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
use uuid::Uuid;
|
||||
|
||||
const TOKEN: &str = "shared-secret-for-tests";
|
||||
|
||||
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
struct Harness {
|
||||
@@ -32,12 +29,17 @@ struct Harness {
|
||||
_fx: DaemonFixture,
|
||||
}
|
||||
|
||||
async fn spawn_daemon_ws(daemon_id: &str) -> Harness {
|
||||
let fx = DaemonFixture::new(daemon_id);
|
||||
async fn spawn_daemon_ws() -> Harness {
|
||||
let fx = DaemonFixture::new();
|
||||
let manager = fx.manager.clone();
|
||||
let verifier = JwtVerifier::new(TOKEN, daemon_id);
|
||||
let pair_verifier = PairAttachVerifier::new(TOKEN, daemon_id, std::iter::empty::<String>());
|
||||
let router = attach::router(manager.clone(), verifier, pair_verifier);
|
||||
let router = local_api::router(local_api::state_for(
|
||||
manager.clone(),
|
||||
"test-host",
|
||||
"/home/test",
|
||||
Vec::new(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
std::sync::Arc::new(fx.retained_store()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -58,25 +60,12 @@ async fn spawn_daemon_ws(daemon_id: &str) -> Harness {
|
||||
}
|
||||
}
|
||||
|
||||
fn token_for(session_id: Uuid, daemon_id: &str, valid_seconds: i64) -> String {
|
||||
issue_jwt(
|
||||
TOKEN,
|
||||
&JwtClaims {
|
||||
session_id,
|
||||
daemon_id: daemon_id.into(),
|
||||
exp: (Utc::now() + ChronoDuration::seconds(valid_seconds)).timestamp(),
|
||||
iat: Some(Utc::now().timestamp()),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn connect_attach(
|
||||
addr: SocketAddr,
|
||||
session: Uuid,
|
||||
token: &str,
|
||||
) -> Result<WsClient, tokio_tungstenite::tungstenite::Error> {
|
||||
let url = format!("ws://{addr}/sessions/{session}/stream?token={token}");
|
||||
let url = format!("ws://{addr}/api/attach/{session}");
|
||||
connect_async(url).await.map(|(ws, _)| ws)
|
||||
}
|
||||
|
||||
@@ -86,7 +75,6 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
||||
let s = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: cwd_path,
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -100,42 +88,17 @@ async fn create_bash_session(h: &Harness) -> Uuid {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_bad_token() {
|
||||
let h = spawn_daemon_ws("laptop").await;
|
||||
let id = create_bash_session(&h).await;
|
||||
let err = connect_attach(h.addr, id, "not-a-jwt").await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("401") || msg.contains("403"), "{msg}");
|
||||
let _ = h.manager.kill(id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_token_for_different_session() {
|
||||
let h = spawn_daemon_ws("laptop").await;
|
||||
let real_id = create_bash_session(&h).await;
|
||||
let other_id = Uuid::new_v4();
|
||||
let token = token_for(other_id, "laptop", 300);
|
||||
let err = connect_attach(h.addr, real_id, &token).await.unwrap_err();
|
||||
assert!(format!("{err}").contains("403"));
|
||||
let _ = h.manager.kill(real_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_token_for_different_daemon() {
|
||||
let h = spawn_daemon_ws("laptop").await;
|
||||
let id = create_bash_session(&h).await;
|
||||
let token = token_for(id, "dev-vm", 300);
|
||||
let err = connect_attach(h.addr, id, &token).await.unwrap_err();
|
||||
assert!(format!("{err}").contains("401"));
|
||||
let _ = h.manager.kill(id).await;
|
||||
async fn rejects_unknown_session() {
|
||||
let h = spawn_daemon_ws().await;
|
||||
let err = connect_attach(h.addr, Uuid::new_v4()).await.unwrap_err();
|
||||
assert!(format!("{err}").contains("404"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pty_round_trip_through_real_tmux() {
|
||||
let h = spawn_daemon_ws("laptop").await;
|
||||
let h = spawn_daemon_ws().await;
|
||||
let id = create_bash_session(&h).await;
|
||||
let token = token_for(id, "laptop", 300);
|
||||
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
|
||||
let mut ws = connect_attach(h.addr, id).await.unwrap();
|
||||
|
||||
ws.send(WsMessage::Binary(b"echo MAESTRO_RT_OK\n".to_vec()))
|
||||
.await
|
||||
@@ -159,17 +122,16 @@ async fn pty_round_trip_through_real_tmux() {
|
||||
"marker not seen in {} bytes",
|
||||
got.len()
|
||||
);
|
||||
let _ = h.manager.kill(id).await;
|
||||
let _ = h.manager.kill(id, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_attach_broadcasts_to_every_client() {
|
||||
let h = spawn_daemon_ws("laptop").await;
|
||||
let h = spawn_daemon_ws().await;
|
||||
let id = create_bash_session(&h).await;
|
||||
let token = token_for(id, "laptop", 300);
|
||||
|
||||
let mut ws_a = connect_attach(h.addr, id, &token).await.unwrap();
|
||||
let mut ws_b = connect_attach(h.addr, id, &token).await.unwrap();
|
||||
let mut ws_a = connect_attach(h.addr, id).await.unwrap();
|
||||
let mut ws_b = connect_attach(h.addr, id).await.unwrap();
|
||||
|
||||
// give the broadcast subscribers a moment to register
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
@@ -208,7 +170,7 @@ async fn multi_attach_broadcasts_to_every_client() {
|
||||
"client B missed marker"
|
||||
);
|
||||
|
||||
let _ = h.manager.kill(id).await;
|
||||
let _ = h.manager.kill(id, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -216,10 +178,9 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() {
|
||||
// Bug NAP-226: when bash exits, tmux destroys the session, the liveness
|
||||
// watcher reaps it, and the broadcast channel closes — but the WS used to
|
||||
// be dropped silently, leaving the client hung in raw mode.
|
||||
let h = spawn_daemon_ws("laptop").await;
|
||||
let h = spawn_daemon_ws().await;
|
||||
let id = create_bash_session(&h).await;
|
||||
let token = token_for(id, "laptop", 300);
|
||||
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
|
||||
let mut ws = connect_attach(h.addr, id).await.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
@@ -251,15 +212,14 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn sends_close_frame_when_session_is_killed() {
|
||||
let h = spawn_daemon_ws("laptop").await;
|
||||
let h = spawn_daemon_ws().await;
|
||||
let id = create_bash_session(&h).await;
|
||||
let token = token_for(id, "laptop", 300);
|
||||
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
|
||||
let mut ws = connect_attach(h.addr, id).await.unwrap();
|
||||
|
||||
// give the broadcast subscriber a moment to register before killing.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
h.manager.kill(id).await.unwrap();
|
||||
h.manager.kill(id, false).await.unwrap();
|
||||
|
||||
let mut saw_close = false;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
@@ -287,10 +247,9 @@ async fn sends_close_frame_when_session_is_killed() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_text_frame_gets_pong() {
|
||||
let h = spawn_daemon_ws("laptop").await;
|
||||
let h = spawn_daemon_ws().await;
|
||||
let id = create_bash_session(&h).await;
|
||||
let token = token_for(id, "laptop", 300);
|
||||
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
|
||||
let mut ws = connect_attach(h.addr, id).await.unwrap();
|
||||
|
||||
let ping = serde_json::to_string(&maestro_protocol::ClientControl::Ping).unwrap();
|
||||
ws.send(WsMessage::Text(ping)).await.unwrap();
|
||||
@@ -308,18 +267,17 @@ async fn ping_text_frame_gets_pong() {
|
||||
}
|
||||
}
|
||||
assert!(saw_pong, "no Pong frame received");
|
||||
let _ = h.manager.kill(id).await;
|
||||
let _ = h.manager.kill(id, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_attach_sees_replay_buffer_of_prior_output() {
|
||||
// NAP-229: a fresh attach must receive the buffered scrollback before
|
||||
// any live frames, so reconnecting to an idle prompt isn't blank.
|
||||
let h = spawn_daemon_ws("laptop").await;
|
||||
let h = spawn_daemon_ws().await;
|
||||
let session = create_bash_session(&h).await;
|
||||
let token = token_for(session, "laptop", 60);
|
||||
|
||||
let mut a = connect_attach(h.addr, session, &token).await.unwrap();
|
||||
let mut a = connect_attach(h.addr, session).await.unwrap();
|
||||
// Generate output the bash will echo back.
|
||||
a.send(WsMessage::Binary(b"echo MAESTRO_REPLAY_MARK\n".to_vec()))
|
||||
.await
|
||||
@@ -349,7 +307,7 @@ async fn second_attach_sees_replay_buffer_of_prior_output() {
|
||||
|
||||
// Now attach a fresh client. It should see the prior MARK in the replay
|
||||
// even though no new output has happened since.
|
||||
let mut b = connect_attach(h.addr, session, &token).await.unwrap();
|
||||
let mut b = connect_attach(h.addr, session).await.unwrap();
|
||||
let saw_replay = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
let mut buf = Vec::new();
|
||||
while let Some(Ok(msg)) = b.next().await {
|
||||
@@ -369,3 +327,57 @@ async fn second_attach_sees_replay_buffer_of_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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The performance contract of #83: the blocked-work pass must cost NO git
|
||||
//! probe at all for a session without a worktree, while a session with one is
|
||||
//! still probed.
|
||||
//!
|
||||
//! `git` itself is shimmed through PATH with a wrapper that logs every
|
||||
//! invocation before exec'ing the real binary, so the assertion counts actual
|
||||
//! probes rather than inferring them from state. This lives in its own test
|
||||
//! binary because it mutates the process environment.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||
use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
cwd: cwd.to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.to_path_buf(),
|
||||
branch: branch.into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_without_a_worktree_costs_no_probe() {
|
||||
let fx = DaemonFixture::new();
|
||||
let log = TempDir::new().unwrap();
|
||||
let log_path = log.path().join("git-calls.log");
|
||||
|
||||
// Session A owns a real (pristine, pushed) worktree; session B has none.
|
||||
let repo_a = TempDir::new().unwrap();
|
||||
init_repo(repo_a.path());
|
||||
init_bare_repo(&repo_a.path().join("origin.git"));
|
||||
push_all_to_origin(repo_a.path(), &repo_a.path().join("origin.git"));
|
||||
fx.manager
|
||||
.create(request(repo_a.path(), repo_a.path(), "feat/probed"))
|
||||
.await
|
||||
.expect("create A");
|
||||
|
||||
let cwd_b = TempDir::new().unwrap();
|
||||
let mut req_b = request(cwd_b.path(), cwd_b.path(), "feat/unused");
|
||||
req_b.worktree = None;
|
||||
fx.manager.create(req_b).await.expect("create B");
|
||||
|
||||
// Shim `git` AFTER setup so only watcher-driven probes get logged.
|
||||
let real_git = String::from_utf8(
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("command -v git")
|
||||
.output()
|
||||
.expect("locate git")
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
let shim_dir = TempDir::new().unwrap();
|
||||
let script = format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexec {} \"$@\"\n",
|
||||
log_path.display(),
|
||||
real_git,
|
||||
);
|
||||
std::fs::write(shim_dir.path().join("git"), script).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(
|
||||
shim_dir.path().join("git"),
|
||||
std::fs::Permissions::from_mode(0o755),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let old_path = std::env::var("PATH").unwrap_or_default();
|
||||
std::env::set_var(
|
||||
"PATH",
|
||||
format!("{}:{}", shim_dir.path().display(), old_path),
|
||||
);
|
||||
|
||||
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||
Duration::from_millis(50),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
|
||||
// The pass demonstrably runs and probed A's tree...
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager
|
||||
.snapshot()
|
||||
.await
|
||||
.iter()
|
||||
.any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default()))
|
||||
})
|
||||
.await;
|
||||
|
||||
std::env::set_var("PATH", &old_path);
|
||||
|
||||
let calls = std::fs::read_to_string(&log_path).unwrap_or_default();
|
||||
assert!(
|
||||
!calls.is_empty(),
|
||||
"the pass never probed anything — the shim saw no git call at all"
|
||||
);
|
||||
// ...while B — no worktree, so nothing to probe — was never touched.
|
||||
assert!(
|
||||
!calls.contains(cwd_b.path().to_str().unwrap()),
|
||||
"a worktree-less session was probed:\n{calls}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Integration tests for `GET /api/branches` (#55).
|
||||
|
||||
mod common;
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::DaemonFixture;
|
||||
use maestro_daemon::local_api;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
struct Harness {
|
||||
addr: SocketAddr,
|
||||
_shutdown: oneshot::Sender<()>,
|
||||
_fx: DaemonFixture,
|
||||
}
|
||||
|
||||
async fn spawn() -> Harness {
|
||||
let fx = DaemonFixture::new();
|
||||
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
|
||||
let router = local_api::router(local_api::state_for(
|
||||
fx.manager.clone(),
|
||||
"naps-laptop",
|
||||
"/home/test",
|
||||
presets,
|
||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
std::sync::Arc::new(fx.retained_store()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = rx.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
Harness {
|
||||
addr,
|
||||
_shutdown: tx,
|
||||
_fx: fx,
|
||||
}
|
||||
}
|
||||
|
||||
fn git(repo: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
/// A real repo on `main` with a second branch `feature`, and a linked
|
||||
/// worktree at `<tmp>/wt-feature` holding `feature`.
|
||||
fn repo_with_occupied_branch() -> tempfile::TempDir {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
git(repo, &["init", "-q", "-b", "main"]);
|
||||
git(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"init",
|
||||
"--allow-empty",
|
||||
],
|
||||
);
|
||||
git(repo, &["branch", "feature"]);
|
||||
let wt = tmp.path().join("wt-feature");
|
||||
git(repo, &["worktree", "add", &wt.to_string_lossy(), "feature"]);
|
||||
tmp
|
||||
}
|
||||
|
||||
async fn get_branches(addr: SocketAddr, path: &Path) -> Vec<maestro_protocol::BranchInfo> {
|
||||
reqwest::get(format!(
|
||||
"http://{addr}/api/branches?path={}",
|
||||
urlencoding::encode(&path.to_string_lossy())
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// #55: every local branch is listed; the main checkout's HEAD reports
|
||||
/// `is_head`, and a branch held by a linked worktree reports that worktree's
|
||||
/// path so the client can explain why the entry is disabled.
|
||||
#[tokio::test]
|
||||
async fn branches_lists_local_branches_and_worktree_occupancy() {
|
||||
let tmp = repo_with_occupied_branch();
|
||||
let h = spawn().await;
|
||||
|
||||
let mut branches = get_branches(h.addr, tmp.path()).await;
|
||||
branches.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
assert_eq!(
|
||||
branches.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
["feature", "main"]
|
||||
);
|
||||
|
||||
let main = branches.iter().find(|b| b.name == "main").unwrap();
|
||||
assert!(main.is_head, "main is the main checkout's HEAD");
|
||||
assert_eq!(main.worktree, None, "HEAD occupancy rides `is_head`");
|
||||
|
||||
let feature = branches.iter().find(|b| b.name == "feature").unwrap();
|
||||
assert!(!feature.is_head);
|
||||
assert_eq!(
|
||||
feature.worktree.as_deref(),
|
||||
Some(tmp.path().join("wt-feature").as_path()),
|
||||
"the linked worktree holding the branch must be reported"
|
||||
);
|
||||
}
|
||||
|
||||
/// #55: a path outside any repository is not an error — an empty list,
|
||||
/// matching how `/api/gitroot` reports "not a repository".
|
||||
#[tokio::test]
|
||||
async fn branches_returns_empty_for_a_path_outside_a_repository() {
|
||||
let h = spawn().await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plain = tmp.path().join("plain");
|
||||
std::fs::create_dir_all(&plain).unwrap();
|
||||
|
||||
let branches = get_branches(h.addr, &plain).await;
|
||||
assert!(branches.is_empty());
|
||||
}
|
||||
@@ -7,10 +7,12 @@
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use maestro_daemon::sidecar::SidecarStore;
|
||||
use maestro_daemon::spawn_history::SpawnHistory;
|
||||
use maestro_daemon::tmux::Tmux;
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
use maestro_daemon::SessionManager;
|
||||
@@ -91,6 +93,38 @@ fn run(cmd: &mut Command) {
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty bare repository at `dir`, usable as a push target (#79).
|
||||
pub fn init_bare_repo(dir: &Path) {
|
||||
run(Command::new("git")
|
||||
.arg("init")
|
||||
.arg("-q")
|
||||
.arg("--bare")
|
||||
.arg(dir.to_str().expect("non-utf8 bare repo path")));
|
||||
}
|
||||
|
||||
/// Push every branch of `repo` to `origin`, creating remote-tracking refs.
|
||||
///
|
||||
/// The unsaved-work probe (#79) counts commits reachable from no
|
||||
/// remote-tracking ref as unpushed, so tests that want a killable worktree
|
||||
/// must give its branch one. Safe to call again: re-adding an existing
|
||||
/// remote is ignored.
|
||||
pub fn push_all_to_origin(repo: &Path, origin: &Path) {
|
||||
let _ = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
origin.to_str().expect("non-utf8 origin path"),
|
||||
])
|
||||
.status();
|
||||
run(Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["push", "-q", "--all", "origin"]));
|
||||
}
|
||||
|
||||
/// Async polling sibling of [`wait_for`] for tokio tests.
|
||||
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
|
||||
where
|
||||
@@ -113,35 +147,72 @@ pub struct DaemonFixture {
|
||||
pub tmux: TmuxFixture,
|
||||
pub manager: SessionManager,
|
||||
pub state_dir: TempDir,
|
||||
pub worktree_root: TempDir,
|
||||
}
|
||||
|
||||
impl DaemonFixture {
|
||||
pub fn new(daemon_id: &str) -> Self {
|
||||
pub fn new() -> Self {
|
||||
Self::build(None)
|
||||
}
|
||||
|
||||
/// Variant whose manager has a durable spawn history (#24) attached, with
|
||||
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
|
||||
pub fn new_with_history(retention_days: u32) -> Self {
|
||||
Self::build(Some(retention_days))
|
||||
}
|
||||
|
||||
fn build(history_days: Option<u32>) -> Self {
|
||||
let tmux = TmuxFixture::new();
|
||||
let state_dir = TempDir::new().unwrap();
|
||||
let worktree_root = TempDir::new().unwrap();
|
||||
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||
let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf());
|
||||
let manager = SessionManager::new(
|
||||
daemon_id.to_string(),
|
||||
tmux.tmux.clone(),
|
||||
// Default placement: worktrees land under their base repo (#56).
|
||||
let worktree_mgr = WorktreeManager::new(None);
|
||||
let mut manager = SessionManager::new(
|
||||
Arc::new(tmux.tmux.clone()),
|
||||
sidecar,
|
||||
worktree_mgr,
|
||||
state_dir.path().join("fifos"),
|
||||
);
|
||||
if let Some(days) = history_days {
|
||||
manager =
|
||||
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
|
||||
}
|
||||
// Same wiring as production: the manager records retained worktrees
|
||||
// into the same dir the API reads back from (#78/#81).
|
||||
let retained =
|
||||
maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir));
|
||||
manager = manager.with_retained_store(retained.clone());
|
||||
Self {
|
||||
tmux,
|
||||
manager,
|
||||
state_dir,
|
||||
worktree_root,
|
||||
}
|
||||
}
|
||||
|
||||
fn history_path(state_dir: &TempDir) -> PathBuf {
|
||||
state_dir.path().join("spawn-history.jsonl")
|
||||
}
|
||||
|
||||
fn retained_path(state_dir: &TempDir) -> PathBuf {
|
||||
state_dir.path().join("retained-worktrees")
|
||||
}
|
||||
|
||||
pub fn spawn_history_path(&self) -> PathBuf {
|
||||
Self::history_path(&self.state_dir)
|
||||
}
|
||||
|
||||
pub fn sidecar_dir(&self) -> PathBuf {
|
||||
self.state_dir.path().join("sessions")
|
||||
}
|
||||
|
||||
/// Retained-worktree store rooted at the fixture's own state dir (#78).
|
||||
pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore {
|
||||
maestro_daemon::retained_store::RetainedStore::new(self.retained_dir())
|
||||
}
|
||||
|
||||
pub fn retained_dir(&self) -> PathBuf {
|
||||
self.state_dir.path().join("retained-worktrees")
|
||||
}
|
||||
|
||||
pub fn fifo_dir(&self) -> PathBuf {
|
||||
self.state_dir.path().join("fifos")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! Integration tests for the daemon's local HTTP API + broker WS (NAP-243).
|
||||
//!
|
||||
//! The legacy `/sessions/:id/stream` endpoint is covered by `tests/attach.rs`;
|
||||
//! this file exercises the new `/api/*` surface and the same-host broker WS.
|
||||
//! Integration tests for the daemon's HTTP + WS API (NAP-243).
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -11,8 +8,8 @@ use std::time::Duration;
|
||||
|
||||
use common::DaemonFixture;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_daemon::{local_api, BrokerHandle, CatalogMirror, LocalEventBus};
|
||||
use maestro_protocol::{CreateSessionRequest, DaemonInfo, RegistryEvent, Session};
|
||||
use maestro_daemon::local_api;
|
||||
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
@@ -24,35 +21,42 @@ type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
struct Harness {
|
||||
addr: SocketAddr,
|
||||
manager: maestro_daemon::SessionManager,
|
||||
catalog: CatalogMirror,
|
||||
_shutdown: oneshot::Sender<()>,
|
||||
_fx: DaemonFixture,
|
||||
}
|
||||
|
||||
async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
|
||||
let fx = DaemonFixture::new(daemon_id);
|
||||
impl Harness {
|
||||
/// Store over the same dir the manager records into.
|
||||
fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore {
|
||||
self._fx.retained_store()
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn(hostname: &str) -> Harness {
|
||||
spawn_with(
|
||||
hostname,
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn spawn_with(
|
||||
hostname: &str,
|
||||
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
|
||||
) -> Harness {
|
||||
let fx = DaemonFixture::new();
|
||||
let manager = fx.manager.clone();
|
||||
let catalog = CatalogMirror::new();
|
||||
let events = LocalEventBus::new();
|
||||
let state = local_api::state_for(
|
||||
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
|
||||
// a known set (#26).
|
||||
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
|
||||
let router = local_api::router(local_api::state_for(
|
||||
manager.clone(),
|
||||
hostname,
|
||||
daemon_url,
|
||||
catalog.clone(),
|
||||
events,
|
||||
);
|
||||
// NAP-247: tests don't exercise the cross-host broker path; hand the
|
||||
// router an isolated BrokerHandle whose receiver is dropped, so any
|
||||
// resolve_attach attempt fails fast with NoControlLink.
|
||||
let (broker, _broker_rx) = BrokerHandle::new();
|
||||
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));
|
||||
"/home/test",
|
||||
presets,
|
||||
projects,
|
||||
std::sync::Arc::new(fx.retained_store()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -68,7 +72,6 @@ async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
|
||||
Harness {
|
||||
addr,
|
||||
manager,
|
||||
catalog,
|
||||
_shutdown: tx,
|
||||
_fx: fx,
|
||||
}
|
||||
@@ -78,99 +81,27 @@ fn url(addr: SocketAddr, path: &str) -> String {
|
||||
format!("http://{addr}{path}")
|
||||
}
|
||||
|
||||
fn ws_url(addr: SocketAddr, path: &str) -> String {
|
||||
format!("ws://{addr}{path}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_info_returns_daemon_identity() {
|
||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
||||
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/web/info"))
|
||||
async fn info_returns_daemon_identity() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp["daemon_id"], "laptop");
|
||||
assert_eq!(resp["hostname"], "naps-laptop");
|
||||
assert_eq!(resp["daemon_url"], "ws://10.0.0.1:7070");
|
||||
assert_eq!(resp["home_dir"], "/home/test");
|
||||
assert!(resp["version"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_daemons_includes_self_and_mirrored_peers() {
|
||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
||||
|
||||
// Before any catalog events arrive, the daemon only knows about itself.
|
||||
let list: Vec<DaemonInfo> = reqwest::get(url(h.addr, "/api/daemons"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].daemon_id, "laptop");
|
||||
|
||||
// Simulate the registry fanning out a peer addition.
|
||||
h.catalog
|
||||
.apply(RegistryEvent::DaemonAdded {
|
||||
daemon: DaemonInfo {
|
||||
daemon_id: "konishi".into(),
|
||||
hostname: "konishi-host".into(),
|
||||
version: "0.1.0".into(),
|
||||
daemon_url: "ws://10.0.0.2:7070".into(),
|
||||
},
|
||||
})
|
||||
.await;
|
||||
|
||||
let list: Vec<DaemonInfo> = reqwest::get(url(h.addr, "/api/daemons"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
let ids: Vec<&str> = list.iter().map(|d| d.daemon_id.as_str()).collect();
|
||||
assert!(ids.contains(&"laptop"));
|
||||
assert!(ids.contains(&"konishi"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_unions_own_and_peer() {
|
||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
||||
// Create one local session.
|
||||
let own = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// Inject a peer session via the mirror.
|
||||
let mut peer = own.clone();
|
||||
peer.id = Uuid::new_v4();
|
||||
peer.daemon_id = "konishi".into();
|
||||
h.catalog
|
||||
.apply(RegistryEvent::SessionAdded {
|
||||
session: peer.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
let list: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let ids: Vec<Uuid> = list.iter().map(|s| s.id).collect();
|
||||
assert!(ids.contains(&own.id), "own session missing: {ids:?}");
|
||||
assert!(ids.contains(&peer.id), "peer session missing: {ids:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_starts_empty_and_reflects_creates() {
|
||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
||||
let h = spawn("naps-laptop").await;
|
||||
|
||||
let empty: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||
.await
|
||||
@@ -181,8 +112,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: h.manager.daemon_id().into(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
@@ -197,7 +127,6 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.daemon_id, "laptop");
|
||||
|
||||
let after: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||
.await
|
||||
@@ -210,32 +139,182 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_to_peer_daemon_id_returns_501_not_proxy() {
|
||||
// Cross-daemon broker is NAP-247; until it lands a daemon must refuse
|
||||
// (rather than silently misroute) a create targeting a peer.
|
||||
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()
|
||||
async fn info_advertises_spawn_presets() {
|
||||
// #26: the daemon's presets ride the info payload so the spawn form needs
|
||||
// no extra round-trip. Built-ins + the implicit custom.
|
||||
let h = spawn("naps-laptop").await;
|
||||
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_IMPLEMENTED);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["error"]["code"], "remote_create_unsupported");
|
||||
let ids: Vec<&str> = resp["presets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["id"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, ["claude", "opencode", "zsh", "custom"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recent_dirs_returns_empty_without_history() {
|
||||
// #26: reads this daemon's spawn history. The test fixture wires no
|
||||
// history, so the list is empty but the endpoint is well-formed.
|
||||
let h = spawn("naps-laptop").await;
|
||||
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/recent-dirs"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp["dirs"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(resp["truncated"], false);
|
||||
}
|
||||
|
||||
/// #54: `GET /api/projects` lists every git repo under the scan roots, with
|
||||
/// real git checkouts — not mocks.
|
||||
#[tokio::test]
|
||||
async fn projects_lists_repos_under_the_scan_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
for name in ["alpha", "beta"] {
|
||||
let repo = tmp.path().join(name);
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
for args in [
|
||||
vec!["init", "-q"],
|
||||
vec![
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-qm",
|
||||
"init",
|
||||
"--allow-empty",
|
||||
],
|
||||
] {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(&args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
}
|
||||
}
|
||||
|
||||
let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new(
|
||||
vec![tmp.path().to_path_buf()],
|
||||
3,
|
||||
tmp.path().to_path_buf(),
|
||||
));
|
||||
let h = spawn_with("naps-laptop", scanner).await;
|
||||
|
||||
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["alpha", "beta"]);
|
||||
for p in &projects {
|
||||
assert!(!p.is_worktree);
|
||||
assert_eq!(p.base_repo, None);
|
||||
assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn projects_returns_empty_for_a_disabled_scanner() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(projects.is_empty());
|
||||
}
|
||||
|
||||
/// #53: every returned handle splits into a known adjective and noun.
|
||||
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
|
||||
for name in &batch.names {
|
||||
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
|
||||
panic!("handle {name:?} is not adjective-noun");
|
||||
});
|
||||
assert!(
|
||||
maestro_names::ADJECTIVES.contains(&adj),
|
||||
"{adj} not in ADJECTIVES"
|
||||
);
|
||||
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn names_defaults_to_one_handle() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(batch.names.len(), 1);
|
||||
assert_valid_names(&batch);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn names_honors_count_and_caps_it() {
|
||||
use maestro_names::MAX_BATCH;
|
||||
let h = spawn("naps-laptop").await;
|
||||
|
||||
let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names?count=5"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(batch.names.len(), 5);
|
||||
assert_valid_names(&batch);
|
||||
|
||||
let batch: maestro_protocol::NameBatch =
|
||||
reqwest::get(url(h.addr, &format!("/api/names?count={}", MAX_BATCH * 10)))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(batch.names.len(), MAX_BATCH);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gitroot_probes_path() {
|
||||
// #26: resolves the query against the daemon home dir and walks up for a
|
||||
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
|
||||
let h = spawn("naps-laptop").await;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let resp: serde_json::Value = reqwest::get(url(
|
||||
h.addr,
|
||||
&format!(
|
||||
"/api/gitroot?path={}",
|
||||
urlencoding::encode(&tmp.path().to_string_lossy())
|
||||
),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.get("root").is_none() || resp["root"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_unknown_session_returns_404() {
|
||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
||||
let h = spawn("naps-laptop").await;
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
|
||||
.send()
|
||||
@@ -246,11 +325,10 @@ async fn delete_unknown_session_returns_404() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_owned_session_kills_it() {
|
||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
||||
let h = spawn("naps-laptop").await;
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -268,13 +346,113 @@ async fn delete_owned_session_kills_it() {
|
||||
assert!(!h.manager.has(session.id).await);
|
||||
}
|
||||
|
||||
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
|
||||
/// with the structured breakdown, and the session keeps running.
|
||||
#[tokio::test]
|
||||
async fn broker_ws_attaches_to_local_session() {
|
||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
||||
async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let repo = tempfile::TempDir::new().unwrap();
|
||||
common::init_repo(repo.path());
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
repo.path()
|
||||
.join("worktrees")
|
||||
.join("feat")
|
||||
.join("guarded")
|
||||
.join("README"),
|
||||
b"unsaved edits\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
|
||||
let body: ApiError = resp.json().await.unwrap();
|
||||
assert_eq!(body.error.code, "kill_guarded");
|
||||
let breakdown = body.error.unsaved_work.expect("breakdown on the wire");
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
|
||||
assert!(h.manager.has(session.id).await, "the session keeps running");
|
||||
}
|
||||
|
||||
/// `?force=true` on the wire (#81): the refusal is overridden, the session
|
||||
/// ends, and the answer is 200 with the retained worktree named — path,
|
||||
/// branch, reason — while the directory survives on disk.
|
||||
#[tokio::test]
|
||||
async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let repo = tempfile::TempDir::new().unwrap();
|
||||
common::init_repo(repo.path());
|
||||
let wt_path = repo
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feat")
|
||||
.join("guarded")
|
||||
.to_path_buf();
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
||||
.query(&[("force", "true")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
||||
let body: maestro_protocol::KillOutcome = resp.json().await.unwrap();
|
||||
let retained = body.retained.expect("force names what was kept");
|
||||
assert_eq!(retained.path, wt_path);
|
||||
assert_eq!(retained.branch, "feat/guarded");
|
||||
assert_eq!(
|
||||
retained.reason,
|
||||
maestro_protocol::RetainedReason::ForceKilled
|
||||
);
|
||||
|
||||
// The tree would have blocked; force must not have removed it.
|
||||
assert!(wt_path.exists(), "a forced kill never removes the worktree");
|
||||
// And the store entry exists, so reclaim can find it (#80).
|
||||
let all = h.fx_retained().load_all().unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].id, retained.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_ws_streams_pty_bytes() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: "/tmp".into(),
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -288,7 +466,7 @@ async fn broker_ws_attaches_to_local_session() {
|
||||
let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap();
|
||||
|
||||
// Provoke output. The bash process should echo this back via the PTY.
|
||||
ws.send(WsMessage::Binary(b"echo hello-broker\n".to_vec()))
|
||||
ws.send(WsMessage::Binary(b"echo hello-attach\n".to_vec()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -296,7 +474,7 @@ async fn broker_ws_attaches_to_local_session() {
|
||||
loop {
|
||||
match ws.next().await {
|
||||
Some(Ok(WsMessage::Binary(bytes))) => {
|
||||
if String::from_utf8_lossy(&bytes).contains("hello-broker") {
|
||||
if String::from_utf8_lossy(&bytes).contains("hello-attach") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -307,12 +485,12 @@ async fn broker_ws_attaches_to_local_session() {
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
assert!(saw_hello, "PTY did not echo through the broker WS");
|
||||
assert!(saw_hello, "PTY did not echo through the attach WS");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broker_ws_for_unknown_session_returns_404_not_502() {
|
||||
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
|
||||
async fn attach_ws_for_unknown_session_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let ws_url = format!("ws://{}/api/attach/{}", h.addr, Uuid::new_v4());
|
||||
let err = connect_async(ws_url)
|
||||
.await
|
||||
@@ -323,3 +501,334 @@ async fn broker_ws_for_unknown_session_returns_404_not_502() {
|
||||
"{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, false).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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/worktrees + DELETE /api/worktrees/:id (#80)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `git -C dir args`, asserting success — the worktree endpoints are tested
|
||||
/// against real git, like everything else in this crate.
|
||||
fn git(dir: &std::path::Path, args: &[&str]) {
|
||||
let out = std::process::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)
|
||||
);
|
||||
}
|
||||
|
||||
struct RetainedFixture {
|
||||
repo: tempfile::TempDir,
|
||||
wt: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// A real repo with a linked worktree for branch `feat/login` whose tree holds
|
||||
/// an uncommitted tracked edit and an unpushed commit.
|
||||
fn retained_fixture() -> RetainedFixture {
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
common::init_repo(repo.path());
|
||||
// A pushed main, so "unpushed" means commits made on the feature branch,
|
||||
// not the shared history.
|
||||
let remote = repo.path().join("remote.git");
|
||||
std::fs::create_dir_all(&remote).unwrap();
|
||||
let remote_s = remote.to_string_lossy().into_owned();
|
||||
git(&remote, &["init", "-q", "--bare"]);
|
||||
git(repo.path(), &["remote", "add", "origin", &remote_s]);
|
||||
git(repo.path(), &["push", "-q", "-u", "origin", "main"]);
|
||||
git(
|
||||
repo.path(),
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"feat/login",
|
||||
"worktrees/feat/login",
|
||||
],
|
||||
);
|
||||
let wt = repo.path().join("worktrees").join("feat").join("login");
|
||||
RetainedFixture { repo, wt }
|
||||
}
|
||||
|
||||
impl RetainedFixture {
|
||||
fn commit_in_wt(&self, msg: &str) {
|
||||
std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap();
|
||||
git(&self.wt, &["add", "-A"]);
|
||||
git(&self.wt, &["commit", "-qm", msg]);
|
||||
}
|
||||
|
||||
fn branch_sha(&self, branch: &str) -> String {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(self.repo.path())
|
||||
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "branch {branch} must exist");
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktrees_lists_retained_entries_with_recomputed_breakdown() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
// Uncommitted tracked edit + an untracked file; the branch has no commits
|
||||
// beyond main yet.
|
||||
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap();
|
||||
std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap();
|
||||
|
||||
let store = h._fx.retained_store();
|
||||
let entry = store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
let w = &listed[0];
|
||||
assert_eq!(w.id, entry.id);
|
||||
assert_eq!(w.path, fx.wt);
|
||||
assert_eq!(w.base_repo, fx.repo.path());
|
||||
assert_eq!(w.branch, "feat/login");
|
||||
assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled);
|
||||
assert_eq!(w.in_the_way.uncommitted_files, 1);
|
||||
assert_eq!(w.in_the_way.untracked_files, 1);
|
||||
assert_eq!(w.in_the_way.unpushed_commits, 0);
|
||||
|
||||
// The operator saves the work themselves. The stored entry is untouched —
|
||||
// the next read must report the tree as it is *now*, not as it was when
|
||||
// it was retained.
|
||||
fx.commit_in_wt("saved");
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.len(), 1, "same entry, still retained");
|
||||
assert_eq!(listed[0].id, entry.id);
|
||||
assert_eq!(listed[0].in_the_way.uncommitted_files, 0);
|
||||
assert_eq!(listed[0].in_the_way.unpushed_commits, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_removes_directory_and_entry_but_never_the_branch() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
fx.commit_in_wt("precious");
|
||||
let sha = fx.branch_sha("feat/login");
|
||||
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse
|
||||
|
||||
let store = h._fx.retained_store();
|
||||
let entry = store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
assert!(!fx.wt.exists(), "reclaim removes the directory");
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(listed.is_empty(), "reclaim drops the store entry");
|
||||
assert_eq!(
|
||||
fx.branch_sha("feat/login"),
|
||||
sha,
|
||||
"the branch and its commits survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
let store = h._fx.retained_store();
|
||||
let entry = store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_unknown_id_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4())))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
let store = h._fx.retained_store();
|
||||
store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(&fx.wt).unwrap();
|
||||
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(listed.is_empty(), "a hand-removed directory is not listed");
|
||||
assert!(
|
||||
store.load_all().unwrap().is_empty(),
|
||||
"and its entry is gone"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,211 +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",
|
||||
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",
|
||||
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",
|
||||
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, false).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(), false)
|
||||
.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,26 @@ mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{wait_for_async, DaemonFixture};
|
||||
use maestro_daemon::SessionEvent;
|
||||
use common::{
|
||||
init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture, TmuxFixture,
|
||||
};
|
||||
use maestro_daemon::SessionError;
|
||||
use maestro_protocol::CreateSessionRequest;
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_then_kill_round_trip() {
|
||||
let fx = DaemonFixture::new("laptop");
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -42,7 +46,7 @@ async fn create_then_kill_round_trip() {
|
||||
// FIFO should also exist
|
||||
assert!(fx.fifo_dir().join(format!("{}.fifo", session.id)).exists());
|
||||
|
||||
fx.manager.kill(session.id).await.expect("kill");
|
||||
fx.manager.kill(session.id, false).await.expect("kill");
|
||||
assert!(!fx.manager.has(session.id).await);
|
||||
assert!(!fx
|
||||
.sidecar_dir()
|
||||
@@ -50,28 +54,61 @@ async fn create_then_kill_round_trip() {
|
||||
.exists());
|
||||
}
|
||||
|
||||
// #24: a successful spawn appends a record to the durable history, which
|
||||
// `recent_dirs()` then surfaces — proving the create→append→read path end to
|
||||
// end against a real tmux + on-disk JSONL log.
|
||||
#[tokio::test]
|
||||
async fn rejects_wrong_daemon_in_request() {
|
||||
let fx = DaemonFixture::new("laptop");
|
||||
async fn create_appends_to_spawn_history() {
|
||||
let fx = DaemonFixture::new_with_history(30);
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let err = fx
|
||||
|
||||
let session = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "dev-vm".into(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 5".into(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// The JSONL log exists and the recents query surfaces the chosen dir.
|
||||
assert!(fx.spawn_history_path().exists());
|
||||
let recents = fx.manager.recent_dirs();
|
||||
assert_eq!(recents.len(), 1);
|
||||
assert_eq!(recents[0].cwd, cwd.path());
|
||||
assert_eq!(recents[0].last_command, "sleep 30");
|
||||
|
||||
fx.manager.kill(session.id, false).await.expect("kill");
|
||||
// History is durable — it survives the kill that deletes the sidecar.
|
||||
assert_eq!(fx.manager.recent_dirs().len(), 1);
|
||||
}
|
||||
|
||||
// With history disabled (`0` days) or unwired, a spawn logs nothing and the
|
||||
// recents query is empty — the spawn itself still succeeds.
|
||||
#[tokio::test]
|
||||
async fn create_without_history_logs_nothing() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("targets daemon"), "{err}");
|
||||
.expect("create");
|
||||
assert!(fx.manager.recent_dirs().is_empty());
|
||||
assert!(!fx.spawn_history_path().exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recover_adopts_existing_tmux_sessions() {
|
||||
let fx = DaemonFixture::new("laptop");
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
// create a session, then "throw away" the SessionManager (the tmux session
|
||||
@@ -79,7 +116,6 @@ async fn recover_adopts_existing_tmux_sessions() {
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -91,11 +127,9 @@ async fn recover_adopts_existing_tmux_sessions() {
|
||||
|
||||
// build a parallel manager pointing at the same dirs + same tmux server
|
||||
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 worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
|
||||
let revived = maestro_daemon::SessionManager::new(
|
||||
"laptop".to_string(),
|
||||
fx.tmux.tmux.clone(),
|
||||
Arc::new(fx.tmux.tmux.clone()),
|
||||
sidecar,
|
||||
worktree_mgr,
|
||||
fx.fifo_dir(),
|
||||
@@ -108,15 +142,128 @@ async fn recover_adopts_existing_tmux_sessions() {
|
||||
assert_eq!(snap[0].command, "sleep 30");
|
||||
}
|
||||
|
||||
/// Regression for NAP-253: the very first bytes a freshly-spawned command
|
||||
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
||||
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
||||
/// setup, so the child's first output was buffered by tmux but never tee'd
|
||||
/// into the FIFO.
|
||||
#[tokio::test]
|
||||
async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
||||
let fx = DaemonFixture::new("laptop");
|
||||
async fn create_captures_initial_output_in_replay_buffer() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
// The shell prints the marker before doing anything else and
|
||||
// then sleeps so the session stays alive. Before NAP-253 this
|
||||
// marker was the canonical case that got swallowed.
|
||||
command: "sh -c 'echo NAP_253_FIRST_LINE_MARKER; exec sleep 30'".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let want = b"NAP_253_FIRST_LINE_MARKER";
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let mut found = false;
|
||||
while std::time::Instant::now() < deadline {
|
||||
let (replay, _rx) = fx
|
||||
.manager
|
||||
.subscribe_pty_with_replay(s.id)
|
||||
.await
|
||||
.expect("subscribe");
|
||||
if replay.windows(want.len()).any(|w| w == want) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
assert!(
|
||||
found,
|
||||
"first-line marker never reached the replay buffer — pipe-pane raced past the child's initial output",
|
||||
);
|
||||
|
||||
fx.manager.kill(s.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
||||
/// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently
|
||||
/// kept feeding the old FIFO and the revived manager's subscribers saw
|
||||
/// nothing.
|
||||
#[tokio::test]
|
||||
async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// Capture paths + tmux handle before dropping the original manager —
|
||||
// `drop(fx.manager)` partially moves `fx` so we can't call its helpers
|
||||
// afterwards.
|
||||
let sidecar_dir = fx.sidecar_dir();
|
||||
let fifo_dir = fx.fifo_dir();
|
||||
let tmux = fx.tmux.tmux.clone();
|
||||
|
||||
// Drop the original manager. Its PtyBridge closes the FIFO's only
|
||||
// reader, so tmux's `cat >> fifo` writer hits SIGPIPE and exits — but
|
||||
// tmux's pipe-pane bookkeeping still thinks the pane is piped. That's
|
||||
// exactly the stale state recover() has to overwrite.
|
||||
drop(fx.manager);
|
||||
|
||||
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
|
||||
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
|
||||
let revived =
|
||||
maestro_daemon::SessionManager::new(Arc::new(tmux), sidecar, worktree_mgr, fifo_dir);
|
||||
assert_eq!(revived.recover().await.expect("recover"), 1);
|
||||
|
||||
let mut rx = revived.subscribe_pty(s.id).await.expect("subscribe");
|
||||
revived
|
||||
.send_keys(s.id, b"echo NAP_228_RECOVERED\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let want = b"NAP_228_RECOVERED";
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if let Ok(Ok(bytes)) = tokio::time::timeout(Duration::from_millis(50), rx.recv()).await {
|
||||
buf.extend_from_slice(&bytes);
|
||||
if buf.windows(want.len()).any(|w| w == want) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
buf.windows(want.len()).any(|w| w == want),
|
||||
"recover() did not restore pty output flow; buf={:?}",
|
||||
String::from_utf8_lossy(&buf),
|
||||
);
|
||||
|
||||
revived.kill(s.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "bash --noprofile --norc".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -155,19 +302,18 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
||||
assert!(buf_a.windows(want.len()).any(|w| w == want), "A missed");
|
||||
assert!(buf_b.windows(want.len()).any(|w| w == want), "B missed");
|
||||
|
||||
fx.manager.kill(s.id).await.unwrap();
|
||||
fx.manager.kill(s.id, false).await.unwrap();
|
||||
let _ = (cwd, PathBuf::from("/"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn liveness_watcher_reaps_dead_sessions_and_emits_removed() {
|
||||
let fx = DaemonFixture::new("laptop");
|
||||
async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -186,41 +332,604 @@ async fn liveness_watcher_reaps_dead_sessions_and_emits_removed() {
|
||||
// daemon never finds out through its normal channels.
|
||||
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 {
|
||||
!fx.manager.has(s.id).await
|
||||
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||
})
|
||||
.await;
|
||||
|
||||
// Sidecar + FIFO should be cleaned up too.
|
||||
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
||||
// Marked, not removed — the operator still sees it exited, and its
|
||||
// artifacts stay so the output can be replayed.
|
||||
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_removed = false;
|
||||
let mut saw_dead = false;
|
||||
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 {
|
||||
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,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_removed,
|
||||
"expected SessionEvent::Removed for dead session"
|
||||
saw_dead,
|
||||
"expected a SessionUpdated marking the session dead"
|
||||
);
|
||||
|
||||
// Dismissing it is what finally releases the artifacts.
|
||||
fx.manager.kill(s.id, false).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());
|
||||
}
|
||||
|
||||
/// #83: the watcher refreshes `Session.blocked_work` on its own cadence —
|
||||
/// first "nothing blocks" for a pristine tree, then the breakdown once the
|
||||
/// tree picks up unsaved work, so clients can warn before the kill attempt.
|
||||
#[tokio::test]
|
||||
async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("indicator");
|
||||
|
||||
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/indicator".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// Pushed and clean: nothing in the way, and the indicator must say so.
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||
Duration::from_millis(50),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager.snapshot().await[0].blocked_work == Some(maestro_protocol::InTheWay::default())
|
||||
})
|
||||
.await;
|
||||
|
||||
// Unsaved work appears; the next pass must surface it.
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
matches!(
|
||||
&fx.manager.snapshot().await[0].blocked_work,
|
||||
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
// A steady state must not re-emit every tick (#83 change-detection).
|
||||
let mut events = fx.manager.events();
|
||||
while events.try_recv().is_ok() {}
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
assert!(
|
||||
!matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })),
|
||||
"an unchanged worktree must not re-emit SessionUpdated"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
||||
/// that says "don't clean up" loses its worktree — the branch was pushed, so
|
||||
/// nothing in the tree is unique and removal goes ahead.
|
||||
#[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").join("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(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
assert!(wt_path.exists());
|
||||
|
||||
// Push the branch so its commits are reachable from a remote-tracking ref.
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager.kill(s.id, false).await.expect("kill");
|
||||
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
||||
}
|
||||
|
||||
/// The kill guard (#79): a worktree holding uncommitted changes refuses the
|
||||
/// kill outright. Nothing happens — the session keeps running as the
|
||||
/// operator's handle on their unfinished work, and the tree is untouched.
|
||||
/// The refusal names what blocked it and how much of it there is.
|
||||
#[tokio::test]
|
||||
async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("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(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
|
||||
|
||||
let err = fx
|
||||
.manager
|
||||
.kill(s.id, false)
|
||||
.await
|
||||
.expect_err("kill must refuse");
|
||||
let SessionError::KillGuarded(breakdown) = err else {
|
||||
panic!("expected KillGuarded, got {err:?}");
|
||||
};
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
assert_eq!(breakdown.untracked_files, 1);
|
||||
assert!(
|
||||
breakdown.unpushed_commits >= 1,
|
||||
"a fresh branch has no remote-tracking ref"
|
||||
);
|
||||
let message = breakdown.to_string();
|
||||
assert!(message.contains("1 uncommitted file"), "{message}");
|
||||
assert!(message.contains("1 untracked file"), "{message}");
|
||||
|
||||
// Nothing happened: the session survives and so does the tree.
|
||||
assert!(fx.manager.has(s.id).await, "the session must keep running");
|
||||
assert!(wt_path.exists(), "the worktree must be untouched");
|
||||
|
||||
// A second kill after the work is saved goes through.
|
||||
run_git_in(&wt_path, &["add", "."]);
|
||||
run_git_in(&wt_path, &["commit", "-q", "-m", "saved"]);
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
fx.manager
|
||||
.kill(s.id, false)
|
||||
.await
|
||||
.expect("kill after saving");
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
}
|
||||
|
||||
/// Unpushed commits alone block the kill (#79): a feature branch almost
|
||||
/// always has them, which is why `--force` (which retains) is the ordinary
|
||||
/// way to end such a session.
|
||||
#[tokio::test]
|
||||
async fn killing_refuses_until_the_branch_is_pushed() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("pushed");
|
||||
|
||||
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/pushed".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let err = fx
|
||||
.manager
|
||||
.kill(s.id, false)
|
||||
.await
|
||||
.expect_err("unpushed blocks");
|
||||
let SessionError::KillGuarded(breakdown) = err else {
|
||||
panic!("expected KillGuarded, got {err:?}");
|
||||
};
|
||||
assert_eq!(breakdown.uncommitted_files, 0);
|
||||
assert_eq!(breakdown.untracked_files, 0);
|
||||
assert!(breakdown.unpushed_commits >= 1);
|
||||
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager
|
||||
.kill(s.id, false)
|
||||
.await
|
||||
.expect("pushed branch kills");
|
||||
assert!(!wt_path.exists());
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
}
|
||||
|
||||
/// `--force` overrides the refusal and nothing else (#81): the session ends,
|
||||
/// but the guarded worktree is RETAINED — never removed — and enters the
|
||||
/// retained store with reason `force-killed`. The outcome names what was
|
||||
/// kept, so a client can say where the work went.
|
||||
#[tokio::test]
|
||||
async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("forced");
|
||||
|
||||
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/forced".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
// Without force the same kill is refused (#79).
|
||||
assert!(fx.manager.kill(s.id, false).await.is_err());
|
||||
|
||||
let outcome = fx
|
||||
.manager
|
||||
.kill(s.id, true)
|
||||
.await
|
||||
.expect("force overrides the refusal");
|
||||
|
||||
// The tree would have blocked, so force must NOT have removed it...
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"a forced kill never removes a tree that would have blocked"
|
||||
);
|
||||
// ...but the session itself still ends.
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||
|
||||
let retained = outcome.retained.expect("the outcome names what was kept");
|
||||
assert_eq!(retained.path, wt_path);
|
||||
assert_eq!(retained.branch, "feat/forced");
|
||||
assert_eq!(
|
||||
retained.reason,
|
||||
maestro_protocol::RetainedReason::ForceKilled
|
||||
);
|
||||
assert!(
|
||||
retained.in_the_way.uncommitted_files >= 1,
|
||||
"the outcome carries what blocked the unforced kill"
|
||||
);
|
||||
|
||||
// The store holds the same entry, so reclaim can find it later (#80).
|
||||
let all = fx.retained_store().load_all().expect("load_all");
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].id, retained.id);
|
||||
assert_eq!(all[0].path, wt_path);
|
||||
assert_eq!(all[0].branch, "feat/forced");
|
||||
}
|
||||
|
||||
/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose
|
||||
/// guard is clear never needed the override, so a forced kill removes it as
|
||||
/// usual and retains nothing.
|
||||
#[tokio::test]
|
||||
async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("clear");
|
||||
|
||||
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/clear".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
let outcome = fx.manager.kill(s.id, true).await.expect("kill");
|
||||
assert!(!wt_path.exists(), "a clear tree needs no override");
|
||||
assert_eq!(outcome.retained, None);
|
||||
assert!(fx.retained_store().load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Ignored files NEVER block (#79): build output like `target/` exists in
|
||||
/// every Rust checkout, and an ignored-only worktree stays killable.
|
||||
#[tokio::test]
|
||||
async fn killing_an_ignored_only_worktree_succeeds() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
// .gitignore must be committed before the worktree branches off, or it
|
||||
// would itself read as an untracked file.
|
||||
std::fs::write(repo.path().join(".gitignore"), b"/target\n").unwrap();
|
||||
run_git_in(repo.path(), &["add", ".gitignore"]);
|
||||
run_git_in(repo.path(), &["commit", "-q", "-m", "ignore target"]);
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("rusty");
|
||||
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/rusty".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::create_dir_all(wt_path.join("target")).unwrap();
|
||||
std::fs::write(wt_path.join("target").join("app.o"), b"binary\n").unwrap();
|
||||
|
||||
// Branch pushed; the only dirt is ignored output.
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager
|
||||
.kill(s.id, false)
|
||||
.await
|
||||
.expect("ignored files never block");
|
||||
assert!(!wt_path.exists());
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
}
|
||||
|
||||
/// Natural exit (#82): a session that ends on its own never reaches the kill
|
||||
/// path, so the watcher applies the kill's rule itself. A worktree holding
|
||||
/// unsaved work is RETAINED into the store with reason
|
||||
/// `exited-with-unsaved-work`; the session stays in the catalog marked dead
|
||||
/// until dismissed — and dismissing it afterwards neither refuses nor
|
||||
/// retains a second time.
|
||||
#[tokio::test]
|
||||
async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("exited");
|
||||
|
||||
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/exited".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
// The command finishes / the operator types exit: tmux loses the session.
|
||||
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||
})
|
||||
.await;
|
||||
|
||||
// The tree would have blocked a kill, so the exit kept it...
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"a dirty worktree must be retained on natural exit"
|
||||
);
|
||||
// ...in the store, under the exit reason.
|
||||
let all = fx.retained_store().load_all().expect("load_all");
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].path, wt_path);
|
||||
assert_eq!(all[0].branch, "feat/exited");
|
||||
assert_eq!(
|
||||
all[0].reason,
|
||||
maestro_protocol::RetainedReason::ExitedWithUnsavedWork
|
||||
);
|
||||
|
||||
// The session itself stays until dismissed; its worktree reference is
|
||||
// settled, so dismissal is neither refused nor retained twice.
|
||||
// (`Dead` is observable a moment before the settle step finishes.)
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
let s = &fx.manager.snapshot().await[0];
|
||||
s.worktree.is_none() && s.blocked_work.is_none()
|
||||
})
|
||||
.await;
|
||||
let snap = fx.manager.snapshot().await;
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(snap[0].worktree, None);
|
||||
|
||||
fx.manager.kill(s.id, false).await.expect("dismiss");
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"dismissal must not remove what was retained"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.retained_store().load_all().unwrap().len(),
|
||||
1,
|
||||
"dismissal must not record a second entry for the same tree"
|
||||
);
|
||||
}
|
||||
|
||||
/// Natural exit (#82), pristine side of the same rule: nothing in the tree,
|
||||
/// nothing to keep — the worktree is removed and no store entry appears.
|
||||
#[tokio::test]
|
||||
async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("clean");
|
||||
|
||||
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/clean".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// Push everything so the branch has no unpushed commits: fully pristine.
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!wt_path.exists(),
|
||||
"a pristine worktree leaves nothing behind"
|
||||
);
|
||||
assert!(
|
||||
fx.retained_store().load_all().unwrap().is_empty(),
|
||||
"nothing was retained"
|
||||
);
|
||||
assert!(
|
||||
fx.manager.has(s.id).await,
|
||||
"the session stays until dismissed"
|
||||
);
|
||||
}
|
||||
|
||||
/// When nothing can be recorded — here: no retained store wired — the exit
|
||||
/// destroys nothing. The tree stays on disk AND stays attached to the dead
|
||||
/// record, where dismissing it later still goes through #81's forced-kill
|
||||
/// retention. Losing the store must not lose the work.
|
||||
#[tokio::test]
|
||||
async fn natural_exit_without_a_retained_store_destroys_nothing() {
|
||||
let tmux = TmuxFixture::new();
|
||||
let state_dir = TempDir::new().unwrap();
|
||||
let manager = maestro_daemon::SessionManager::new(
|
||||
Arc::new(tmux.tmux.clone()),
|
||||
maestro_daemon::sidecar::SidecarStore::new(state_dir.path().join("sessions")),
|
||||
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||
state_dir.path().join("fifos"),
|
||||
);
|
||||
// Deliberately NO with_retained_store.
|
||||
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("nostore");
|
||||
|
||||
let s = 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/nostore".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||
manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"nothing may be destroyed when it cannot be recorded"
|
||||
);
|
||||
let snap = manager.snapshot().await;
|
||||
assert!(
|
||||
snap[0].worktree.is_some(),
|
||||
"the tree must stay attached to the record so a later forced kill can retain it"
|
||||
);
|
||||
}
|
||||
|
||||
fn run_git_in(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)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||
let fx = DaemonFixture::new("laptop");
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
daemon_id: "laptop".into(),
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
@@ -236,5 +945,5 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
assert!(fx.manager.has(s.id).await, "live session got reaped");
|
||||
|
||||
fx.manager.kill(s.id).await.unwrap();
|
||||
fx.manager.kill(s.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -12,18 +12,23 @@ use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sample(id: Uuid, daemon_id: &str) -> Session {
|
||||
fn sample(id: Uuid) -> Session {
|
||||
Session {
|
||||
id,
|
||||
daemon_id: daemon_id.to_string(),
|
||||
cwd: PathBuf::from("/tmp/work"),
|
||||
command: "bash".into(),
|
||||
env: HashMap::from([("FOO".into(), "bar".into())]),
|
||||
metadata: HashMap::from([("label".into(), Value::String("personal/maestro".into()))]),
|
||||
metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]),
|
||||
worktree: None,
|
||||
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: Some(PathBuf::from("/tmp/work/crates")),
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +37,7 @@ fn write_then_read_round_trip() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
let s = sample(id, "laptop");
|
||||
let s = sample(id);
|
||||
|
||||
store.write(&s).expect("write");
|
||||
let loaded = store.read(id).expect("read");
|
||||
@@ -55,7 +60,7 @@ fn delete_removes_file_and_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
let id = Uuid::new_v4();
|
||||
let s = sample(id, "laptop");
|
||||
let s = sample(id);
|
||||
|
||||
store.write(&s).unwrap();
|
||||
assert!(store.path_for(id).exists());
|
||||
@@ -72,8 +77,8 @@ fn load_all_recovers_every_sidecar_and_skips_garbage() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = SidecarStore::new(dir.path());
|
||||
|
||||
let s1 = sample(Uuid::new_v4(), "laptop");
|
||||
let s2 = sample(Uuid::new_v4(), "dev-vm");
|
||||
let s1 = sample(Uuid::new_v4());
|
||||
let s2 = sample(Uuid::new_v4());
|
||||
store.write(&s1).unwrap();
|
||||
store.write(&s2).unwrap();
|
||||
|
||||
@@ -82,9 +87,9 @@ fn load_all_recovers_every_sidecar_and_skips_garbage() {
|
||||
std::fs::write(dir.path().join("broken.json"), b"{not json}").unwrap();
|
||||
|
||||
let mut loaded = store.load_all().expect("load_all");
|
||||
loaded.sort_by_key(|s| s.daemon_id.clone());
|
||||
loaded.sort_by_key(|s| s.id);
|
||||
let mut expected = vec![s1, s2];
|
||||
expected.sort_by_key(|s| s.daemon_id.clone());
|
||||
expected.sort_by_key(|s| s.id);
|
||||
assert_eq!(loaded, expected);
|
||||
}
|
||||
|
||||
@@ -100,7 +105,7 @@ fn write_creates_parent_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let nested = dir.path().join("a/b/c");
|
||||
let store = SidecarStore::new(&nested);
|
||||
let s = sample(Uuid::new_v4(), "laptop");
|
||||
let s = sample(Uuid::new_v4());
|
||||
store.write(&s).expect("write");
|
||||
assert!(nested.exists());
|
||||
assert!(store.path_for(s.id).exists());
|
||||
|
||||
@@ -6,8 +6,9 @@ mod common;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use common::init_repo;
|
||||
use common::{init_bare_repo, init_repo, push_all_to_origin};
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
use maestro_daemon::worktree_probe::in_the_way;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
|
||||
@@ -31,9 +32,9 @@ fn add_creates_worktree_on_new_branch() {
|
||||
init_repo(&repo);
|
||||
|
||||
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.join("README").exists());
|
||||
assert!(path.starts_with(wt_root.path()));
|
||||
@@ -42,6 +43,82 @@ fn add_creates_worktree_on_new_branch() {
|
||||
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").join("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()));
|
||||
|
||||
// The repo's basename sits between the root and the branch slug, so two
|
||||
// repos spawning the same branch name don't target one directory.
|
||||
let repo_name = repo.file_name().unwrap();
|
||||
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||
// The override root disambiguates same-named checkouts with a digest of
|
||||
// the full repo path, and the branch keeps its own shape.
|
||||
// <root>/<repo basename>-<digest>/feat/example
|
||||
let prefix = format!("{}-", repo_name.to_string_lossy());
|
||||
let repo_dir_name = path
|
||||
.strip_prefix(wt_root.path())
|
||||
.unwrap()
|
||||
.components()
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
repo_dir_name
|
||||
.as_os_str()
|
||||
.to_string_lossy()
|
||||
.starts_with(&prefix),
|
||||
"{path:?}"
|
||||
);
|
||||
assert!(path.ends_with("feat/example"), "{path:?}");
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
/// Anything at all occupying the target path — ours, the operator's, or an
|
||||
/// unrelated directory — is sidestepped, not adopted and not reported (#77).
|
||||
#[test]
|
||||
fn an_occupied_target_path_gets_a_numeric_suffix() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// A non-git directory sits exactly where the worktree would go.
|
||||
let squatter = repo.join("worktrees").join("feat").join("collide");
|
||||
std::fs::create_dir_all(&squatter).unwrap();
|
||||
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
|
||||
assert_eq!(path, repo.join("worktrees").join("feat").join("collide-2"));
|
||||
assert!(path.exists());
|
||||
assert!(
|
||||
squatter.join("operator-notes.txt").exists(),
|
||||
"the occupant is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_twice_generates_distinct_paths() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
@@ -49,15 +126,132 @@ fn add_twice_generates_distinct_paths() {
|
||||
init_repo(&repo);
|
||||
|
||||
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 p2 = mgr.add(&repo, "feat/b").expect("add b");
|
||||
let p1 = mgr.add_new(&repo, "feat/a").expect("add a");
|
||||
let p2 = mgr.add_new(&repo, "feat/b").expect("add b");
|
||||
assert_ne!(p1, p2);
|
||||
assert!(p1.exists());
|
||||
assert!(p2.exists());
|
||||
}
|
||||
|
||||
/// The unsaved-work probe (#79) counts uncommitted tracked changes.
|
||||
#[test]
|
||||
fn the_probe_counts_uncommitted_tracked_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();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 1);
|
||||
assert_eq!(w.untracked_files, 0);
|
||||
}
|
||||
|
||||
/// Untracked sources count; a staged new file is also unsaved tracked work.
|
||||
#[test]
|
||||
fn the_probe_counts_untracked_files() {
|
||||
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/untracked").expect("add");
|
||||
std::fs::write(path.join("notes.txt"), b"wip\n").unwrap();
|
||||
std::fs::write(path.join("more.txt"), b"also wip\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.untracked_files, 2);
|
||||
assert_eq!(w.uncommitted_files, 0);
|
||||
|
||||
run_git(&path, &["add", "notes.txt"]);
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 1, "staged counts as uncommitted");
|
||||
assert_eq!(w.untracked_files, 1);
|
||||
}
|
||||
|
||||
/// Ignored files NEVER block: every Rust checkout has a `target/`, and
|
||||
/// counting ignored output would leave no session killable (#79).
|
||||
#[test]
|
||||
fn the_probe_ignores_ignored_files() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// .gitignore must be committed, or it would itself read as untracked.
|
||||
std::fs::write(repo.join(".gitignore"), b"/target\n").unwrap();
|
||||
run_git(&repo, &["add", ".gitignore"]);
|
||||
run_git(&repo, &["commit", "-q", "-m", "ignore target"]);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/builds").expect("add");
|
||||
std::fs::create_dir_all(path.join("target")).unwrap();
|
||||
std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 0);
|
||||
assert_eq!(w.untracked_files, 0);
|
||||
|
||||
// With the branch pushed too, an ignored-only tree reads fully clean.
|
||||
let origin_dir = TempDir::new().unwrap();
|
||||
init_bare_repo(origin_dir.path());
|
||||
push_all_to_origin(&repo, origin_dir.path());
|
||||
let w = in_the_way(&path).expect("probe after push");
|
||||
assert!(w.is_clear(), "ignored-only worktree must read clean: {w:?}");
|
||||
}
|
||||
|
||||
/// Commits reachable from no remote-tracking ref block; pushing the branch
|
||||
/// clears them. With no remote at all every commit is unreachable from one,
|
||||
/// which is exactly why feature branches almost always refuse (#79).
|
||||
#[test]
|
||||
fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() {
|
||||
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/branch").expect("add");
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert!(
|
||||
w.unpushed_commits >= 1,
|
||||
"a fresh branch has no remote-tracking ref: {w:?}"
|
||||
);
|
||||
|
||||
let origin_dir = TempDir::new().unwrap();
|
||||
init_bare_repo(origin_dir.path());
|
||||
push_all_to_origin(&repo, origin_dir.path());
|
||||
|
||||
let w = in_the_way(&path).expect("probe after push");
|
||||
assert_eq!(w.unpushed_commits, 0, "{w:?}");
|
||||
assert!(w.is_clear(), "pushed and clean must be empty: {w:?}");
|
||||
}
|
||||
|
||||
/// The full breakdown names each blocker with its count.
|
||||
#[test]
|
||||
fn the_probe_reports_every_blocker_at_once() {
|
||||
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/mixed").expect("add");
|
||||
std::fs::write(path.join("README"), b"edited\n").unwrap();
|
||||
std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 1);
|
||||
assert_eq!(w.untracked_files, 1);
|
||||
assert!(w.unpushed_commits >= 1);
|
||||
let summary = w.to_string();
|
||||
assert!(summary.contains("1 uncommitted file"), "{summary}");
|
||||
assert!(summary.contains("1 untracked file"), "{summary}");
|
||||
assert!(summary.contains("unpushed commit"), "{summary}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_prunes_worktree() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
@@ -65,9 +259,9 @@ fn remove_prunes_worktree() {
|
||||
init_repo(&repo);
|
||||
|
||||
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());
|
||||
|
||||
mgr.remove(&repo, &path).expect("remove");
|
||||
@@ -79,6 +273,138 @@ fn remove_prunes_worktree() {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Removal is guarded upstream now (#79): the kill refuses unsaved work
|
||||
/// before anything is torn down, so by the time `remove` runs the tree is
|
||||
/// clean. Git still refuses a dirty tree on its own — this asserts that last
|
||||
/// line of defense — and committed-but-unpushed work does not block removal,
|
||||
/// because `worktree remove` keeps `refs/heads/<branch>` and the commits stay
|
||||
/// reachable.
|
||||
#[test]
|
||||
fn remove_still_relies_on_git_refusing_a_dirty_tree() {
|
||||
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");
|
||||
|
||||
run_git(&path, &["add", "."]);
|
||||
run_git(&path, &["commit", "-q", "-m", "saved"]);
|
||||
|
||||
mgr.remove(&repo, &path).expect("committed work can go");
|
||||
assert!(!path.exists());
|
||||
assert!(
|
||||
worktree_branches(&repo).contains(&"main".to_string()),
|
||||
"the branch survives removal"
|
||||
);
|
||||
let branches = String::from_utf8(
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(branches.contains("feat/dirty"), "{branches}");
|
||||
}
|
||||
|
||||
/// A worktree whose directory vanished must be pruned, not misread as holding
|
||||
/// unsaved work — otherwise the registration is stranded and, because paths
|
||||
/// are deterministic, that branch can never be spawned again (#56 review).
|
||||
#[test]
|
||||
fn remove_prunes_a_worktree_whose_directory_vanished() {
|
||||
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/gone").expect("add");
|
||||
std::fs::remove_dir_all(&path).unwrap();
|
||||
|
||||
mgr.remove(&repo, &path)
|
||||
.expect("a vanished worktree prunes");
|
||||
// The registration is gone too, so the same branch can be spawned again.
|
||||
mgr.add_existing(&repo, "feat/gone")
|
||||
.expect("branch is spawnable again");
|
||||
}
|
||||
|
||||
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]
|
||||
fn add_fails_when_branch_already_exists() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
@@ -95,9 +421,152 @@ fn add_fails_when_branch_already_exists() {
|
||||
assert!(status.success());
|
||||
|
||||
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}");
|
||||
assert!(msg.contains("git exited"), "{msg}");
|
||||
}
|
||||
|
||||
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
|
||||
/// is easily a worktree rather than the main checkout. Placing inside it would
|
||||
/// nest one worktree in another — and git hides a registered worktree's
|
||||
/// contents from the outer `git status`, so the outer tree reads clean and
|
||||
/// removing it deletes the inner one's uncommitted work (#54 review).
|
||||
#[test]
|
||||
fn placement_resolves_a_linked_worktree_to_the_main_checkout() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
|
||||
|
||||
// Spawn again with the *worktree* as the base, the way the picker would.
|
||||
let inner = mgr.add_new(&outer, "feat/inner").expect("add inner");
|
||||
assert!(
|
||||
!inner.starts_with(&outer),
|
||||
"must not nest: {inner:?} inside {outer:?}"
|
||||
);
|
||||
assert!(
|
||||
inner.starts_with(repo.canonicalize().unwrap().join("worktrees")),
|
||||
"placed against the main checkout: {inner:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Belt and braces for a nesting that predates this daemon: refuse to remove
|
||||
/// a worktree that has another registered underneath it.
|
||||
#[test]
|
||||
fn remove_refuses_a_worktree_holding_a_nested_one() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
|
||||
// Register one by hand inside `outer`, bypassing our placement.
|
||||
let nested = outer.join("worktrees").join("hand-rolled");
|
||||
run_git(
|
||||
&repo,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
"feat/hand",
|
||||
nested.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
std::fs::write(nested.join("notes.txt"), b"unsaved\n").unwrap();
|
||||
|
||||
mgr.remove(&repo, &outer)
|
||||
.expect_err("a worktree holding a nested one must be refused");
|
||||
assert!(nested.join("notes.txt").exists(), "inner work survives");
|
||||
}
|
||||
|
||||
/// Two repos spawning the same branch name under one override root must not
|
||||
/// collide (#54 review).
|
||||
#[test]
|
||||
fn override_root_keeps_repos_apart() {
|
||||
let root_dir = TempDir::new().unwrap();
|
||||
let a_dir = TempDir::new().unwrap();
|
||||
let b_dir = TempDir::new().unwrap();
|
||||
init_repo(a_dir.path());
|
||||
init_repo(b_dir.path());
|
||||
|
||||
let mgr = WorktreeManager::new(Some(root_dir.path().to_path_buf()));
|
||||
let a = mgr.add_new(a_dir.path(), "feat/shared").expect("repo a");
|
||||
let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b");
|
||||
assert_ne!(a, b, "same branch name in two repos must not collide");
|
||||
}
|
||||
|
||||
/// Flattening `/` and `.` to `-` made these three share one directory, so the
|
||||
/// losers could never get a worktree (#54 review).
|
||||
#[test]
|
||||
fn similar_branch_names_get_distinct_directories() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let a = mgr.add_new(&repo, "feat/x").expect("feat/x");
|
||||
let b = mgr.add_new(&repo, "feat.x").expect("feat.x");
|
||||
let c = mgr.add_new(&repo, "feat-x").expect("feat-x");
|
||||
assert_ne!(a, b);
|
||||
assert_ne!(b, c);
|
||||
assert_ne!(a, c);
|
||||
assert!(a.ends_with("feat/x"), "{a:?}");
|
||||
}
|
||||
|
||||
/// A guarded leftover stays on disk untouched, and is never adopted (#77).
|
||||
/// Re-spawning its branch is refused by git itself — the branch is still
|
||||
/// checked out inside the leftover — which the caller surfaces.
|
||||
#[test]
|
||||
fn a_guarded_leftover_is_never_adopted() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let first = mgr.add_new(&repo, "feat/resume").expect("add");
|
||||
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
|
||||
|
||||
// Removal is refused — git itself refuses a dirty tree — leaving it on
|
||||
// disk; the daemon-side guard lives on the kill (#79).
|
||||
mgr.remove(&repo, &first)
|
||||
.expect_err("dirty tree is guarded");
|
||||
|
||||
let err = mgr
|
||||
.add_existing(&repo, "feat/resume")
|
||||
.expect_err("a branch already checked out cannot be spawned twice");
|
||||
assert!(format!("{err}").contains("git exited"), "{err}");
|
||||
assert!(
|
||||
first.join("notes.txt").exists(),
|
||||
"the leftover's unsaved work is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `worktree_root` override gets the same collision suffix (#77).
|
||||
#[test]
|
||||
fn the_override_root_suffixes_collisions_too() {
|
||||
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()));
|
||||
|
||||
// First add settles `<root>/<repo basename>-<digest>/` so the squatter
|
||||
// can be created at the exact target.
|
||||
mgr.add_new(&repo, "feat/first").expect("add first");
|
||||
let repo_dir_in_root = std::fs::read_dir(wt_root.path())
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let squatter = repo_dir_in_root.join("feat").join("collide");
|
||||
std::fs::create_dir_all(&squatter).unwrap();
|
||||
|
||||
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
|
||||
assert_eq!(path, squatter.with_file_name("collide-2"), "{path:?}");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Cross-process integration tests: real registry + daemons as subprocesses"
|
||||
description = "Cross-process integration tests: a real daemon as a subprocess"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
@@ -13,8 +13,8 @@ path = "src/lib.rs"
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
futures-util.workspace = true
|
||||
maestro-protocol.workspace = true
|
||||
reqwest.workspace = true
|
||||
maestro-protocol = { workspace = true, features = ["client"] }
|
||||
maestro-testing.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -26,4 +26,5 @@ tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
maestro-tui.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
//! 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)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{ConnectInfo, CreateSessionRequest, DaemonInfo, RegistryEvent, Session};
|
||||
use reqwest::Client as HttpClient;
|
||||
use std::sync::Arc;
|
||||
use maestro_protocol::{
|
||||
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project,
|
||||
RetainedWorktree, Session,
|
||||
};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
@@ -18,137 +23,86 @@ use uuid::Uuid;
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub enum ClientError {
|
||||
#[error("http: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error(transparent)]
|
||||
Api(#[from] maestro_protocol::ClientError),
|
||||
#[error("ws: {0}")]
|
||||
Ws(#[from] tokio_tungstenite::tungstenite::Error),
|
||||
#[error("json: {0}")]
|
||||
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)]
|
||||
pub struct TestClient {
|
||||
http_base: String,
|
||||
ws_base: String,
|
||||
http: HttpClient,
|
||||
daemon: DaemonClient,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub fn new(http_base: &str, ws_base: &str) -> Self {
|
||||
let http = HttpClient::builder()
|
||||
.cookie_store(true)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("reqwest client");
|
||||
pub fn new(http_base: &str) -> Self {
|
||||
Self {
|
||||
http_base: http_base.trim_end_matches('/').to_string(),
|
||||
ws_base: ws_base.trim_end_matches('/').to_string(),
|
||||
http,
|
||||
daemon: DaemonClient::with_timeouts(http_base, TEST_TIMEOUT, TEST_TIMEOUT),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hit any GET endpoint so the registry issues the auth cookie that all
|
||||
/// subsequent calls reuse.
|
||||
pub async fn bootstrap(&self) -> Result<(), ClientError> {
|
||||
let _ = self.list_daemons().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_daemons(&self) -> Result<Vec<DaemonInfo>, ClientError> {
|
||||
let r = self
|
||||
.http
|
||||
.get(format!("{}/api/daemons", self.http_base))
|
||||
.send()
|
||||
.await?;
|
||||
check_ok(&r)?;
|
||||
Ok(r.json().await?)
|
||||
/// The shared client underneath, for calls this harness doesn't wrap.
|
||||
pub fn daemon(&self) -> &DaemonClient {
|
||||
&self.daemon
|
||||
}
|
||||
|
||||
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
||||
let r = self
|
||||
.http
|
||||
.get(format!("{}/api/sessions", self.http_base))
|
||||
.send()
|
||||
.await?;
|
||||
check_ok(&r)?;
|
||||
Ok(r.json().await?)
|
||||
Ok(self.daemon.list_sessions().await?)
|
||||
}
|
||||
|
||||
pub async fn spawn_session(&self, req: &CreateSessionRequest) -> Result<Session, ClientError> {
|
||||
let r = self
|
||||
.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?)
|
||||
Ok(self.daemon.create_session(req).await?)
|
||||
}
|
||||
|
||||
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
|
||||
let r = self
|
||||
.http
|
||||
.delete(format!("{}/api/sessions/{id}", self.http_base))
|
||||
.send()
|
||||
.await?;
|
||||
check_ok(&r)?;
|
||||
Ok(())
|
||||
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
|
||||
}
|
||||
|
||||
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?)
|
||||
/// Kill with `force=true`: overrides a kill refusal and retains the
|
||||
/// guarded worktree (#81). Answers with what was kept.
|
||||
pub async fn kill_session_forced(&self, id: Uuid) -> Result<KillOutcome, ClientError> {
|
||||
Ok(self.daemon.kill_session(id, true).await?)
|
||||
}
|
||||
|
||||
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
|
||||
Ok(self.daemon.list_worktrees().await?)
|
||||
}
|
||||
|
||||
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
|
||||
Ok(self.daemon.reclaim_worktree(id).await?)
|
||||
}
|
||||
|
||||
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||
Ok(self.daemon.list_projects().await?)
|
||||
}
|
||||
|
||||
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
|
||||
Ok(self.daemon.list_branches(path).await?)
|
||||
}
|
||||
|
||||
/// Connect to the daemon's per-session WS and return the stream. The
|
||||
/// caller drives the conversation.
|
||||
pub async fn attach(
|
||||
&self,
|
||||
info: &ConnectInfo,
|
||||
id: Uuid,
|
||||
) -> Result<WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>, ClientError> {
|
||||
let url = format!(
|
||||
"{}/sessions/{id}/stream?token={}",
|
||||
info.daemon_url.trim_end_matches('/'),
|
||||
info.jwt
|
||||
);
|
||||
let request = url.into_client_request()?;
|
||||
let request = self.daemon.attach_url(id)?.into_client_request()?;
|
||||
let (ws, _resp) = connect_async(request).await?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
/// Subscribe to the registry's `/api/events` WS. Returns a tokio task
|
||||
/// handle that drives the WS and pushes parsed events into an Arc<Mutex<Vec>>.
|
||||
/// Subscribe to the daemon's `/api/events` WS. Returns a handle whose
|
||||
/// background task pushes parsed events into a shared vector.
|
||||
pub async fn subscribe_events(&self) -> EventStream {
|
||||
// Forward the latest cookie value with the upgrade — `reqwest` and
|
||||
// `tungstenite` don't share cookie jars, so we copy the cookie out by
|
||||
// hand. The registry will accept us either way (missing cookie just
|
||||
// gets a fresh one), but reusing the same identity keeps log noise
|
||||
// down.
|
||||
let cookie_header = self.grab_cookie().await.unwrap_or_default();
|
||||
let url = format!("{}/api/events", self.ws_base);
|
||||
let mut request = url.into_client_request().expect("ws req");
|
||||
if !cookie_header.is_empty() {
|
||||
request.headers_mut().insert(
|
||||
reqwest::header::COOKIE,
|
||||
cookie_header.parse().expect("cookie hv"),
|
||||
);
|
||||
}
|
||||
let url = self.daemon.events_url().expect("events url");
|
||||
let request = url.into_client_request().expect("ws req");
|
||||
let (ws, _resp) = connect_async(request).await.expect("events ws");
|
||||
|
||||
let events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
@@ -158,7 +112,7 @@ impl TestClient {
|
||||
while let Some(msg) = stream.next().await {
|
||||
match msg {
|
||||
Ok(WsMessage::Text(t)) => {
|
||||
if let Ok(ev) = serde_json::from_str::<RegistryEvent>(&t) {
|
||||
if let Ok(ev) = serde_json::from_str::<CatalogEvent>(&t) {
|
||||
stream_events.lock().await.push(ev);
|
||||
}
|
||||
}
|
||||
@@ -170,56 +124,17 @@ impl TestClient {
|
||||
});
|
||||
EventStream { events, task }
|
||||
}
|
||||
|
||||
async fn grab_cookie(&self) -> Option<String> {
|
||||
// Force a request to make sure the jar has a cookie, then read it back
|
||||
// via a debug header roundtrip. reqwest's CookieStore isn't directly
|
||||
// exposed; the simplest path is to ask the registry for an auth
|
||||
// cookie and capture the Set-Cookie from the response.
|
||||
let r = self
|
||||
.http
|
||||
.get(format!("{}/api/daemons", self.http_base))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
// The cookie store has the cookie now, but reqwest doesn't surface
|
||||
// it. We rebuild from Set-Cookie if present.
|
||||
r.headers()
|
||||
.get_all(reqwest::header::SET_COOKIE)
|
||||
.iter()
|
||||
.find_map(|hv| hv.to_str().ok().and_then(extract_cookie_pair))
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_cookie_pair(set_cookie: &str) -> Option<String> {
|
||||
// Take everything before the first ';', which is `name=value`.
|
||||
set_cookie
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
fn check_ok(r: &reqwest::Response) -> Result<(), ClientError> {
|
||||
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.
|
||||
#[derive(Debug)]
|
||||
pub struct EventStream {
|
||||
events: Arc<tokio::sync::Mutex<Vec<RegistryEvent>>>,
|
||||
events: Arc<tokio::sync::Mutex<Vec<CatalogEvent>>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl EventStream {
|
||||
pub async fn snapshot(&self) -> Vec<RegistryEvent> {
|
||||
pub async fn snapshot(&self) -> Vec<CatalogEvent> {
|
||||
self.events.lock().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Subprocess wrapper around `maestrod`.
|
||||
//! Subprocess wrapper around `maestro daemon`.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
@@ -10,69 +10,72 @@ use tempfile::TempDir;
|
||||
use crate::build::workspace_target_bin;
|
||||
use crate::util::{free_port, wait_for_async};
|
||||
|
||||
/// A live `maestrod` running in a child process. Kill the child on drop.
|
||||
/// A live daemon running in a child process. Kill the child on drop.
|
||||
///
|
||||
/// `state_dir`, `home`, and `tmux_tmpdir` are kept as `TempDir`s so they
|
||||
/// outlive the process and get cleaned up automatically.
|
||||
/// `home` and `tmux_tmpdir` are kept as `TempDir`s so they outlive the process
|
||||
/// and get cleaned up automatically. State lives under `home` via
|
||||
/// `XDG_STATE_HOME`.
|
||||
#[derive(Debug)]
|
||||
pub struct Daemon {
|
||||
pub daemon_id: String,
|
||||
pub bind_addr: SocketAddr,
|
||||
pub daemon_url: String,
|
||||
child: Option<Child>,
|
||||
home: TempDir,
|
||||
state: TempDir,
|
||||
tmux: TempDir,
|
||||
registry_url: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Optional knobs for `Daemon::spawn_with` — perimeter + allow_inbound for
|
||||
/// NAP-247 broker tests.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DaemonOptions {
|
||||
pub perimeter_id: Option<String>,
|
||||
pub private_addr: Option<String>,
|
||||
pub allow_inbound: Vec<String>,
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
/// Spawn a fresh daemon pointing at `registry_base_url` (the registry's
|
||||
/// HTTP base, e.g. `http://127.0.0.1:1234`). The daemon derives its WS
|
||||
/// endpoint internally via `derive_daemon_ws_url`.
|
||||
pub async fn spawn(daemon_id: &str, registry_base_url: &str, token: &str) -> Self {
|
||||
Self::spawn_with(
|
||||
daemon_id,
|
||||
registry_base_url,
|
||||
token,
|
||||
DaemonOptions::default(),
|
||||
/// Spawn a fresh daemon on a free localhost port.
|
||||
pub async fn spawn() -> Self {
|
||||
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
|
||||
}
|
||||
|
||||
/// Spawn with a config file pre-seeded at the daemon's expected path
|
||||
/// (`$XDG_CONFIG_HOME/maestro/config.toml`).
|
||||
pub async fn spawn_with_config(toml: &str) -> Self {
|
||||
Self::spawn_config_at(
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())),
|
||||
Some(toml),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Spawn with NAP-247 options (perimeter advertisement, allow_inbound).
|
||||
pub async fn spawn_with(
|
||||
daemon_id: &str,
|
||||
registry_base_url: &str,
|
||||
token: &str,
|
||||
opts: DaemonOptions,
|
||||
) -> Self {
|
||||
let port = free_port();
|
||||
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
||||
Self::spawn_with_port_and_opts(daemon_id, registry_base_url, token, bind, opts).await
|
||||
/// Spawn on a caller-supplied bind address.
|
||||
pub async fn spawn_at(bind: SocketAddr) -> Self {
|
||||
Self::spawn_config_at(bind, None).await
|
||||
}
|
||||
|
||||
/// Spawn on a caller-supplied bind address. Used by NAP-247 broker tests
|
||||
/// where the daemon's advertised `private_addr` must equal the actual
|
||||
/// bind address so the broker can dial it back.
|
||||
pub async fn spawn_at(
|
||||
daemon_id: &str,
|
||||
registry_base_url: &str,
|
||||
token: &str,
|
||||
bind: SocketAddr,
|
||||
opts: DaemonOptions,
|
||||
) -> Self {
|
||||
Self::spawn_with_port_and_opts(daemon_id, registry_base_url, token, bind, opts).await
|
||||
/// Spawn on a caller-supplied bind address, with an optional config file.
|
||||
async fn spawn_config_at(bind: SocketAddr, config: impl Into<Option<&str>>) -> Self {
|
||||
let home = TempDir::new().expect("home tmpdir");
|
||||
let tmux = TempDir::new().expect("tmux tmpdir");
|
||||
|
||||
if let Some(toml) = config.into() {
|
||||
let dir = home.path().join(".config").join("maestro");
|
||||
std::fs::create_dir_all(&dir).expect("config dir");
|
||||
std::fs::write(dir.join("config.toml"), toml).expect("write config.toml");
|
||||
}
|
||||
|
||||
let child = launch(bind, home.path(), tmux.path());
|
||||
let me = Self {
|
||||
bind_addr: bind,
|
||||
child: Some(child),
|
||||
home,
|
||||
tmux,
|
||||
};
|
||||
me.wait_until_ready().await;
|
||||
me
|
||||
}
|
||||
|
||||
/// HTTP base URL of this daemon's API.
|
||||
pub fn http_base(&self) -> String {
|
||||
format!("http://{}", self.bind_addr)
|
||||
}
|
||||
|
||||
/// Shared client bound to this daemon, with the harness's tighter budget.
|
||||
pub fn client(&self) -> maestro_protocol::DaemonClient {
|
||||
crate::client::TestClient::new(&self.http_base())
|
||||
.daemon()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Stop the underlying process, but keep its state dirs around so we can
|
||||
@@ -86,75 +89,18 @@ impl Daemon {
|
||||
|
||||
/// Re-spawn the daemon on the same port + state. The previous tmux
|
||||
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
|
||||
/// its sessions. Restarts inherit the original options via the on-disk
|
||||
/// config file written at `spawn_with_port_and_opts` time.
|
||||
/// its sessions.
|
||||
pub async fn restart(&mut self) {
|
||||
self.stop();
|
||||
let child = launch(
|
||||
&self.daemon_id,
|
||||
self.bind_addr,
|
||||
&self.daemon_url,
|
||||
&self.registry_url,
|
||||
&self.token,
|
||||
self.home.path(),
|
||||
self.state.path(),
|
||||
self.tmux.path(),
|
||||
&DaemonOptions::default(),
|
||||
);
|
||||
self.child = Some(child);
|
||||
self.wait_until_attach_ready().await;
|
||||
self.child = Some(launch(self.bind_addr, self.home.path(), self.tmux.path()));
|
||||
self.wait_until_ready().await;
|
||||
}
|
||||
|
||||
async fn spawn_with_port_and_opts(
|
||||
daemon_id: &str,
|
||||
registry_url: &str,
|
||||
token: &str,
|
||||
bind: SocketAddr,
|
||||
opts: DaemonOptions,
|
||||
) -> Self {
|
||||
let home = TempDir::new().expect("home tmpdir");
|
||||
let state = TempDir::new().expect("state tmpdir");
|
||||
let tmux = TempDir::new().expect("tmux tmpdir");
|
||||
|
||||
// Write a minimal config.toml so allow_inbound (and any future
|
||||
// file-only fields) reach the daemon. Required fields keep coming
|
||||
// through env vars set by `launch`.
|
||||
write_config(home.path(), &opts);
|
||||
|
||||
let daemon_url = format!("ws://{bind}");
|
||||
let child = launch(
|
||||
daemon_id,
|
||||
bind,
|
||||
&daemon_url,
|
||||
registry_url,
|
||||
token,
|
||||
home.path(),
|
||||
state.path(),
|
||||
tmux.path(),
|
||||
&opts,
|
||||
);
|
||||
let me = Self {
|
||||
daemon_id: daemon_id.to_string(),
|
||||
bind_addr: bind,
|
||||
daemon_url,
|
||||
child: Some(child),
|
||||
home,
|
||||
state,
|
||||
tmux,
|
||||
registry_url: registry_url.to_string(),
|
||||
token: token.to_string(),
|
||||
};
|
||||
me.wait_until_attach_ready().await;
|
||||
me
|
||||
}
|
||||
|
||||
async fn wait_until_attach_ready(&self) {
|
||||
async fn wait_until_ready(&self) {
|
||||
let addr = self.bind_addr;
|
||||
wait_for_async(
|
||||
"daemon attach port",
|
||||
Duration::from_secs(5),
|
||||
|| async move { tokio::net::TcpStream::connect(addr).await.is_ok() },
|
||||
)
|
||||
wait_for_async("daemon api port", Duration::from_secs(5), || async move {
|
||||
tokio::net::TcpStream::connect(addr).await.is_ok()
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -164,7 +110,7 @@ impl Daemon {
|
||||
|
||||
pub fn state_dir(&self) -> PathBuf {
|
||||
// Matches maestro_daemon::default_state_dir which uses XDG_STATE_HOME.
|
||||
self.home.path().join(".local/state/maestrod")
|
||||
self.home.path().join(".local/state/maestro")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,20 +123,10 @@ impl Drop for Daemon {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn launch(
|
||||
daemon_id: &str,
|
||||
bind: SocketAddr,
|
||||
daemon_url: &str,
|
||||
registry_url: &str,
|
||||
token: &str,
|
||||
home: &std::path::Path,
|
||||
_state: &std::path::Path,
|
||||
tmux_tmpdir: &std::path::Path,
|
||||
opts: &DaemonOptions,
|
||||
) -> Child {
|
||||
let bin = workspace_target_bin("maestro-daemon", "maestrod");
|
||||
fn launch(bind: SocketAddr, home: &std::path::Path, tmux_tmpdir: &std::path::Path) -> Child {
|
||||
let bin = workspace_target_bin("maestro", "maestro");
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.arg("daemon");
|
||||
cmd.env_clear()
|
||||
.env(
|
||||
"PATH",
|
||||
@@ -200,43 +136,11 @@ fn launch(
|
||||
.env("XDG_CONFIG_HOME", home.join(".config"))
|
||||
.env("XDG_STATE_HOME", home.join(".local/state"))
|
||||
.env("TMUX_TMPDIR", tmux_tmpdir)
|
||||
.env("MAESTRO_TOKEN", token)
|
||||
.env("MAESTRO_DAEMON_ID", daemon_id)
|
||||
.env("MAESTRO_REGISTRY_URL", registry_url)
|
||||
.env("MAESTRO_DAEMON_BIND_ADDR", bind.to_string())
|
||||
.env("MAESTRO_DAEMON_URL", daemon_url)
|
||||
.env("RUST_LOG", "warn")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::inherit())
|
||||
.arg("run");
|
||||
if let Some(p) = &opts.perimeter_id {
|
||||
cmd.env("MAESTRO_DAEMON_PERIMETER_ID", p);
|
||||
}
|
||||
if let Some(a) = &opts.private_addr {
|
||||
cmd.env("MAESTRO_DAEMON_PRIVATE_ADDR", a);
|
||||
}
|
||||
cmd.spawn()
|
||||
.unwrap_or_else(|e| panic!("spawn {} failed: {e}", bin.display()))
|
||||
}
|
||||
|
||||
/// Write a minimal `~/.config/maestro/config.toml` carrying the NAP-247
|
||||
/// `[daemon.policy] allow_inbound` list. Everything else still comes from
|
||||
/// env vars set by `launch`.
|
||||
fn write_config(home: &std::path::Path, opts: &DaemonOptions) {
|
||||
if opts.allow_inbound.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dir = home.join(".config/maestro");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut allow = String::new();
|
||||
for (i, id) in opts.allow_inbound.iter().enumerate() {
|
||||
if i > 0 {
|
||||
allow.push_str(", ");
|
||||
}
|
||||
allow.push('"');
|
||||
allow.push_str(id);
|
||||
allow.push('"');
|
||||
}
|
||||
let body = format!("[daemon.policy]\nallow_inbound = [{allow}]\n");
|
||||
std::fs::write(dir.join("config.toml"), body).unwrap();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Cross-process integration test harness for Maestro.
|
||||
//!
|
||||
//! Each test spawns a real `maestro-registry` and one or more `maestrod`
|
||||
//! subprocesses, isolates them in temp dirs (`$HOME`, `TMUX_TMPDIR`, state
|
||||
//! dir), and drives them through their public HTTP/WS APIs.
|
||||
//! Each test spawns a real `maestro daemon` subprocess, isolates it in temp
|
||||
//! dirs (`$HOME`, `TMUX_TMPDIR`, state dir), and drives it through its public
|
||||
//! HTTP/WS API.
|
||||
//!
|
||||
//! See `tests/` for the actual scenarios.
|
||||
|
||||
@@ -11,10 +11,8 @@
|
||||
pub mod build;
|
||||
pub mod client;
|
||||
pub mod daemon;
|
||||
pub mod registry;
|
||||
pub mod util;
|
||||
|
||||
pub use build::workspace_target_bin;
|
||||
pub use client::TestClient;
|
||||
pub use daemon::{Daemon, DaemonOptions};
|
||||
pub use registry::Registry;
|
||||
pub use client::{ClientError, TestClient};
|
||||
pub use daemon::Daemon;
|
||||
|
||||
@@ -1,94 +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-registry", "maestro-registry");
|
||||
let child = Command::new(&bin)
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1119
-408
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Interactive install wizard for maestrod + maestro: `cargo install` both binaries and (on systemd hosts) drop a user unit"
|
||||
description = "Interactive install wizard for the unified `maestro` binary; cargo-installs it and (on systemd hosts) drops a user unit"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
@@ -41,7 +41,16 @@ impl SystemctlAction {
|
||||
/// Build the args for `cargo install <args...>` (excluding the `cargo` and
|
||||
/// `install` words themselves). `locked` adds `--locked` so the install uses
|
||||
/// the committed `Cargo.lock`.
|
||||
pub fn cargo_install_args(source: &CargoSource, crate_name: &str, locked: bool) -> Vec<String> {
|
||||
/// Build the `cargo install` argument vector.
|
||||
///
|
||||
/// `features`, when `Some`, restricts the build to an explicit feature set via
|
||||
/// `--no-default-features --features <list>`.
|
||||
pub fn cargo_install_args(
|
||||
source: &CargoSource,
|
||||
crate_name: &str,
|
||||
locked: bool,
|
||||
features: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
match source {
|
||||
CargoSource::Git { url, branch } => {
|
||||
@@ -60,6 +69,11 @@ pub fn cargo_install_args(source: &CargoSource, crate_name: &str, locked: bool)
|
||||
args.push(crate_name.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(list) = features {
|
||||
args.push("--no-default-features".into());
|
||||
args.push("--features".into());
|
||||
args.push(list.to_string());
|
||||
}
|
||||
if locked {
|
||||
args.push("--locked".into());
|
||||
}
|
||||
@@ -101,13 +115,30 @@ mod tests {
|
||||
url: "https://git.naps.pt/naps62/maestro".into(),
|
||||
branch: None,
|
||||
};
|
||||
let args = cargo_install_args(&src, "maestro-daemon", false);
|
||||
let args = cargo_install_args(&src, "maestro", false, None);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["--git", "https://git.naps.pt/naps62/maestro", "maestro",]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_args_with_features_omit_defaults() {
|
||||
let src = CargoSource::Git {
|
||||
url: "https://git.naps.pt/naps62/maestro".into(),
|
||||
branch: None,
|
||||
};
|
||||
let args = cargo_install_args(&src, "maestro", true, Some("cli,daemon,tui"));
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"--git",
|
||||
"https://git.naps.pt/naps62/maestro",
|
||||
"maestro-daemon",
|
||||
"maestro",
|
||||
"--no-default-features",
|
||||
"--features",
|
||||
"cli,daemon,tui",
|
||||
"--locked",
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -118,7 +149,7 @@ mod tests {
|
||||
url: "https://git.example/x".into(),
|
||||
branch: Some("main".into()),
|
||||
};
|
||||
let args = cargo_install_args(&src, "maestro-cli", true);
|
||||
let args = cargo_install_args(&src, "maestro", true, None);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
@@ -126,7 +157,7 @@ mod tests {
|
||||
"https://git.example/x",
|
||||
"--branch",
|
||||
"main",
|
||||
"maestro-cli",
|
||||
"maestro",
|
||||
"--locked",
|
||||
]
|
||||
);
|
||||
@@ -135,41 +166,35 @@ mod tests {
|
||||
#[test]
|
||||
fn path_args_use_p_flag() {
|
||||
let src = CargoSource::Path(PathBuf::from("/repos/maestro"));
|
||||
let args = cargo_install_args(&src, "maestro-daemon", true);
|
||||
let args = cargo_install_args(&src, "maestro", true, None);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"--path",
|
||||
"/repos/maestro",
|
||||
"-p",
|
||||
"maestro-daemon",
|
||||
"--locked",
|
||||
]
|
||||
vec!["--path", "/repos/maestro", "-p", "maestro", "--locked",]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systemctl_daemon_reload_omits_unit() {
|
||||
let args = systemctl_user_args(SystemctlAction::DaemonReload, "maestrod.service");
|
||||
let args = systemctl_user_args(SystemctlAction::DaemonReload, "maestro.service");
|
||||
assert_eq!(args, vec!["--user", "daemon-reload"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systemctl_enable_includes_unit() {
|
||||
let args = systemctl_user_args(SystemctlAction::Enable, "maestrod.service");
|
||||
assert_eq!(args, vec!["--user", "enable", "maestrod.service"]);
|
||||
let args = systemctl_user_args(SystemctlAction::Enable, "maestro.service");
|
||||
assert_eq!(args, vec!["--user", "enable", "maestro.service"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systemctl_start_includes_unit() {
|
||||
let args = systemctl_user_args(SystemctlAction::Start, "maestrod.service");
|
||||
assert_eq!(args, vec!["--user", "start", "maestrod.service"]);
|
||||
let args = systemctl_user_args(SystemctlAction::Start, "maestro.service");
|
||||
assert_eq!(args, vec!["--user", "start", "maestro.service"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn systemctl_restart_includes_unit() {
|
||||
let args = systemctl_user_args(SystemctlAction::Restart, "maestrod.service");
|
||||
assert_eq!(args, vec!["--user", "restart", "maestrod.service"]);
|
||||
let args = systemctl_user_args(SystemctlAction::Restart, "maestro.service");
|
||||
assert_eq!(args, vec!["--user", "restart", "maestro.service"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! `maestro-installer` — install wizard for maestrod + maestro.
|
||||
//! `maestro-installer` — install wizard for the unified `maestro` binary
|
||||
//! (NAP-245).
|
||||
//!
|
||||
//! See `docs/INSTALL.md` for the user-facing flow. This crate exposes the
|
||||
//! testable pieces:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! `maestro-install` — interactive wizard that installs `maestrod` + `maestro`
|
||||
//! via `cargo install`, optionally drops a systemd user unit, and optionally
|
||||
//! kicks off `maestrod configure`.
|
||||
//! `maestro-install` — interactive wizard that installs the unified `maestro`
|
||||
//! binary via `cargo install` and optionally drops a systemd user unit.
|
||||
//!
|
||||
//! Intended bootstrap path:
|
||||
//!
|
||||
@@ -22,8 +21,10 @@ use maestro_installer::{
|
||||
};
|
||||
|
||||
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
|
||||
const DAEMON_CRATE: &str = "maestro-daemon";
|
||||
const CLI_CRATE: &str = "maestro-cli";
|
||||
/// Unified `maestro` crate, built with the full host feature set.
|
||||
const MAESTRO_CRATE: &str = "maestro";
|
||||
/// Features installed for a host: CLI + per-host daemon + TUI.
|
||||
const MAESTRO_HOST_FEATURES: &str = "cli,daemon,tui";
|
||||
const WEB_CRATE: &str = "maestro-web";
|
||||
const SYSTEMD_PROBE: &str = "/run/systemd/system";
|
||||
|
||||
@@ -31,7 +32,7 @@ const SYSTEMD_PROBE: &str = "/run/systemd/system";
|
||||
#[command(
|
||||
name = "maestro-install",
|
||||
version,
|
||||
about = "Install maestrod + maestro CLI, and (optionally) a systemd user unit"
|
||||
about = "Install the unified `maestro` binary, and (optionally) a systemd user unit"
|
||||
)]
|
||||
struct Args {
|
||||
/// Git URL to install from (default: this repo).
|
||||
@@ -51,7 +52,7 @@ struct Args {
|
||||
#[arg(long)]
|
||||
no_locked: bool,
|
||||
|
||||
/// Don't prompt — accept all defaults (install + systemd + configure).
|
||||
/// Don't prompt — accept all defaults (install + systemd).
|
||||
#[arg(long, short)]
|
||||
yes: bool,
|
||||
|
||||
@@ -59,12 +60,8 @@ struct Args {
|
||||
#[arg(long)]
|
||||
no_systemd: bool,
|
||||
|
||||
/// Skip the `maestrod configure` step.
|
||||
#[arg(long)]
|
||||
no_configure: bool,
|
||||
|
||||
/// Override the ExecStart path written to the systemd unit.
|
||||
/// Default: `~/.cargo/bin/maestrod`.
|
||||
/// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
exec_start: Option<PathBuf>,
|
||||
|
||||
@@ -101,52 +98,25 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 1. Install both binaries.
|
||||
// 1. Install the unified binary (NAP-245).
|
||||
let locked = !args.no_locked;
|
||||
install_crate(&source, DAEMON_CRATE, locked)?;
|
||||
install_crate(&source, CLI_CRATE, locked)?;
|
||||
install_crate(&source, MAESTRO_CRATE, locked, Some(MAESTRO_HOST_FEATURES))?;
|
||||
|
||||
// 2. Offer to run `maestrod configure` now (must happen before systemd
|
||||
// start, since the daemon refuses to run without registry_url / token).
|
||||
// Re-runs of the wizard auto-skip when the unified config already has a
|
||||
// [daemon] section; that's what makes "re-run to update" pleasant. In
|
||||
// non-interactive mode (--yes / no TTY) we also skip — `maestrod
|
||||
// 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 `maestrod configure --force` to rewrite it.)");
|
||||
true
|
||||
} else {
|
||||
run_configure_step(&args, interactive)?
|
||||
}
|
||||
} else {
|
||||
run_configure_step(&args, interactive)?
|
||||
};
|
||||
|
||||
// 3. systemd integration.
|
||||
// 2. systemd integration.
|
||||
let systemd_detected = is_systemd_host(Path::new(SYSTEMD_PROBE));
|
||||
if args.no_systemd {
|
||||
eprintln!("\nSkipping systemd step (--no-systemd).");
|
||||
} else if !systemd_detected {
|
||||
eprintln!("\nNo systemd detected ({SYSTEMD_PROBE} missing). Skipping unit install.");
|
||||
} else {
|
||||
handle_systemd(&args, interactive, configured)?;
|
||||
handle_systemd(&args, interactive)?;
|
||||
}
|
||||
|
||||
// 4. Optional: maestro-web (NAP-249). Re-runs auto-detect an existing
|
||||
// 3. Optional: maestro-web (NAP-249). Re-runs auto-detect an existing
|
||||
// install and refresh rather than re-prompting, matching the daemon flow.
|
||||
let web_installed = handle_web(&args, interactive, systemd_detected)?;
|
||||
|
||||
print_summary(
|
||||
configured,
|
||||
systemd_detected && !args.no_systemd,
|
||||
web_installed,
|
||||
);
|
||||
print_summary(systemd_detected && !args.no_systemd, web_installed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -165,10 +135,9 @@ fn print_banner(source: &CargoSource) {
|
||||
eprintln!("maestro installer");
|
||||
eprintln!();
|
||||
eprintln!("This wizard will:");
|
||||
eprintln!(" 1. cargo install both `{DAEMON_CRATE}` and `{CLI_CRATE}`");
|
||||
eprintln!(" 2. (optional) run `maestrod configure` to bootstrap the config file");
|
||||
eprintln!(" 3. (optional) install a systemd user unit and enable it");
|
||||
eprintln!(" 4. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
|
||||
eprintln!(" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI)");
|
||||
eprintln!(" 2. (optional) install a systemd user unit and enable it");
|
||||
eprintln!(" 3. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
|
||||
eprintln!();
|
||||
match source {
|
||||
CargoSource::Git { url, branch } => {
|
||||
@@ -186,9 +155,10 @@ fn install_crate(
|
||||
source: &CargoSource,
|
||||
crate_name: &str,
|
||||
locked: bool,
|
||||
features: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cargo = cargo_bin();
|
||||
let args = cargo_install_args(source, crate_name, locked);
|
||||
let args = cargo_install_args(source, crate_name, locked, features);
|
||||
eprintln!("\n==> cargo install {}", args.join(" "));
|
||||
let status = Command::new(&cargo).arg("install").args(&args).status()?;
|
||||
if !status.success() {
|
||||
@@ -197,63 +167,7 @@ fn install_crate(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn maestro_config_path() -> Option<PathBuf> {
|
||||
Some(dirs::config_dir()?.join("maestro").join("config.toml"))
|
||||
}
|
||||
|
||||
/// True iff the unified file exists and already has a `[daemon]` section.
|
||||
/// Used to skip the configure prompt on re-runs without forcing the user
|
||||
/// to manually delete a co-located `[registry]` / `[cli]` section first.
|
||||
fn daemon_section_present(path: &Path) -> bool {
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
let Ok(body) = std::fs::read_to_string(path) else {
|
||||
return false;
|
||||
};
|
||||
body.lines().any(|l| l.trim_start().starts_with("[daemon]"))
|
||||
}
|
||||
|
||||
fn run_configure_step(args: &Args, interactive: bool) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let want = interactive
|
||||
&& !args.no_configure
|
||||
&& confirm(
|
||||
"Run `maestrod 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 `maestrod configure` interactively. We don't
|
||||
// hard-code the path; rely on it being on PATH after `cargo install`.
|
||||
eprintln!("\n==> maestrod configure");
|
||||
let status = Command::new("maestrod").arg("configure").status();
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok(true),
|
||||
Ok(s) => {
|
||||
eprintln!("`maestrod configure` exited {s}");
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"couldn't launch `maestrod`: {e}. Make sure ~/.cargo/bin is on PATH, \
|
||||
then run `maestrod configure` manually."
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_systemd(
|
||||
args: &Args,
|
||||
interactive: bool,
|
||||
configured: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn handle_systemd(args: &Args, interactive: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let unit_path = user_unit_path().ok_or("could not resolve ~/.config/systemd/user/")?;
|
||||
let exec_start = match &args.exec_start {
|
||||
Some(p) => p.clone(),
|
||||
@@ -297,7 +211,7 @@ fn handle_systemd(
|
||||
let want_enable = if !interactive {
|
||||
true
|
||||
} else {
|
||||
confirm("Enable maestrod.service (start on login)?", true)?
|
||||
confirm("Enable maestro.service (start on login)?", true)?
|
||||
};
|
||||
if want_enable {
|
||||
run_systemctl(SystemctlAction::Enable, UNIT_FILE_NAME)?;
|
||||
@@ -310,20 +224,14 @@ fn handle_systemd(
|
||||
let want = if !interactive {
|
||||
true
|
||||
} else {
|
||||
confirm("Restart maestrod.service to pick up the new binary?", true)?
|
||||
confirm("Restart maestro.service to pick up the new binary?", true)?
|
||||
};
|
||||
(SystemctlAction::Restart, want)
|
||||
} else {
|
||||
let want = if !interactive {
|
||||
false
|
||||
} else if !configured {
|
||||
eprintln!(
|
||||
"Note: starting now will fail until you run `maestrod configure` \
|
||||
(no registry_url / token yet)."
|
||||
);
|
||||
confirm("Start maestrod.service now anyway?", false)?
|
||||
} else {
|
||||
confirm("Start maestrod.service now?", true)?
|
||||
confirm("Start maestro.service now?", true)?
|
||||
};
|
||||
(SystemctlAction::Start, want)
|
||||
};
|
||||
@@ -335,7 +243,7 @@ fn handle_systemd(
|
||||
}
|
||||
|
||||
fn default_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
maestro_installer::systemd::default_maestrod_path()
|
||||
maestro_installer::systemd::default_maestro_path()
|
||||
.ok_or_else(|| "could not resolve $HOME (needed for ExecStart)".into())
|
||||
}
|
||||
|
||||
@@ -344,6 +252,39 @@ fn default_web_exec_start() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
.ok_or_else(|| "could not resolve $HOME (needed for web ExecStart)".into())
|
||||
}
|
||||
|
||||
/// What to do at the (opt-in) web step given whether pnpm + node are present.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum WebPrereq {
|
||||
/// Toolchain present — install maestro-web normally.
|
||||
Ok,
|
||||
/// Missing and we can ask: prompt the operator to abort or skip.
|
||||
PromptMissing,
|
||||
/// Missing and we can't prompt (non-interactive refresh): warn + skip.
|
||||
SkipMissing,
|
||||
}
|
||||
|
||||
fn classify_web_prereq(toolchain_present: bool, interactive: bool) -> WebPrereq {
|
||||
if toolchain_present {
|
||||
WebPrereq::Ok
|
||||
} else if interactive {
|
||||
WebPrereq::PromptMissing
|
||||
} else {
|
||||
WebPrereq::SkipMissing
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff both `pnpm` and `node` respond to `--version` on PATH. maestro-web's
|
||||
/// build.rs needs them to build the embedded SPA bundle.
|
||||
fn frontend_toolchain_present() -> bool {
|
||||
["pnpm", "node"].iter().all(|tool| {
|
||||
Command::new(tool)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Optional step: install `maestro-web` + its systemd user unit (NAP-249).
|
||||
/// Returns `true` iff the binary was installed (with or without systemd).
|
||||
fn handle_web(
|
||||
@@ -378,10 +319,10 @@ fn handle_web(
|
||||
} else {
|
||||
eprintln!(
|
||||
"\nOptional: install the browser UI (`maestro-web`)?\n \
|
||||
Reverse-proxies a local maestrod and serves the SPA at \
|
||||
Reverse-proxies the local maestro daemon and serves the SPA at \
|
||||
http://127.0.0.1:8080/ by default.\n \
|
||||
Edit [web].bind_addr in ~/.config/maestro/config.toml \
|
||||
to expose it to your perimeter (e.g. WireGuard IP)."
|
||||
to expose it on your LAN/VPN address."
|
||||
);
|
||||
confirm("Install maestro-web?", true)?
|
||||
};
|
||||
@@ -389,12 +330,33 @@ fn handle_web(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// maestro-web's build.rs requires pnpm + node. Check before cargo install so
|
||||
// a missing toolchain surfaces a clear choice instead of a raw build panic.
|
||||
match classify_web_prereq(frontend_toolchain_present(), interactive) {
|
||||
WebPrereq::Ok => {}
|
||||
WebPrereq::PromptMissing => {
|
||||
eprintln!("\nmaestro-web needs pnpm + node, which aren't installed.");
|
||||
if confirm("Abort the whole install? (No = skip web UI)", false)? {
|
||||
return Err("aborted: maestro-web prerequisites (pnpm + node) are missing".into());
|
||||
}
|
||||
eprintln!("Skipping maestro-web install.");
|
||||
return Ok(false);
|
||||
}
|
||||
WebPrereq::SkipMissing => {
|
||||
eprintln!(
|
||||
"\nSkipping maestro-web refresh: pnpm + node not installed. \
|
||||
Existing unit left in place."
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Install the binary. Even if cargo-install fails we keep going so the
|
||||
// operator can fix the cause and re-run; the existing maestrod work
|
||||
// operator can fix the cause and re-run; the existing daemon work
|
||||
// doesn't need to be undone.
|
||||
let source = build_source(args);
|
||||
let locked = !args.no_locked;
|
||||
if let Err(e) = install_crate(&source, WEB_CRATE, locked) {
|
||||
if let Err(e) = install_crate(&source, WEB_CRATE, locked, None) {
|
||||
eprintln!("maestro-web install failed: {e}");
|
||||
eprintln!("(skipping systemd unit; re-run after fixing the build.)");
|
||||
return Ok(false);
|
||||
@@ -485,21 +447,18 @@ fn confirm(prompt: &str, default_yes: bool) -> io::Result<bool> {
|
||||
})
|
||||
}
|
||||
|
||||
fn print_summary(configured: bool, systemd_enabled: bool, web_installed: bool) {
|
||||
fn print_summary(systemd_enabled: bool, web_installed: bool) {
|
||||
eprintln!();
|
||||
eprintln!("Done.");
|
||||
eprintln!();
|
||||
eprintln!("Next steps:");
|
||||
if !configured {
|
||||
eprintln!(" • Run `maestrod configure` to bootstrap your config file.");
|
||||
}
|
||||
if systemd_enabled {
|
||||
eprintln!(" • Check status: `systemctl --user status maestrod`");
|
||||
eprintln!(" • Logs: `journalctl --user -u maestrod -f`");
|
||||
eprintln!(" • Check status: `systemctl --user status maestro`");
|
||||
eprintln!(" • Logs: `journalctl --user -u maestro -f`");
|
||||
} else {
|
||||
eprintln!(" • Start the daemon: `maestrod`");
|
||||
eprintln!(" • Start the daemon: `maestro daemon`");
|
||||
}
|
||||
eprintln!(" • Set the CLI's registry: `maestro set-registry <URL>`");
|
||||
eprintln!(" • Open the TUI: `maestro tui`");
|
||||
if web_installed {
|
||||
if systemd_enabled {
|
||||
eprintln!(
|
||||
@@ -513,3 +472,24 @@ fn print_summary(configured: bool, systemd_enabled: bool, web_installed: bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prereq_ok_when_toolchain_present() {
|
||||
assert_eq!(classify_web_prereq(true, true), WebPrereq::Ok);
|
||||
assert_eq!(classify_web_prereq(true, false), WebPrereq::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prereq_prompts_when_missing_and_interactive() {
|
||||
assert_eq!(classify_web_prereq(false, true), WebPrereq::PromptMissing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prereq_skips_when_missing_and_noninteractive() {
|
||||
assert_eq!(classify_web_prereq(false, false), WebPrereq::SkipMissing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
//! systemd detection + user-unit rendering.
|
||||
//!
|
||||
//! The wizard only ever writes a *user* unit (`~/.config/systemd/user/`), since
|
||||
//! `maestrod` runs per-user and uses tmux sockets in the user's runtime dir.
|
||||
//! `maestro daemon` runs per-user and uses tmux sockets in the user's runtime dir.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Standard probe path. Present on hosts booted under systemd.
|
||||
pub const SYSTEMD_PROBE_PATH: &str = "/run/systemd/system";
|
||||
|
||||
/// Filename written under `~/.config/systemd/user/` for the daemon.
|
||||
pub const UNIT_FILE_NAME: &str = "maestrod.service";
|
||||
/// Filename written under `~/.config/systemd/user/` for the daemon (NAP-245
|
||||
/// renamed this from `maestrod.service`).
|
||||
pub const UNIT_FILE_NAME: &str = "maestro.service";
|
||||
|
||||
/// Filename for the optional `maestro-web` browser UI unit (NAP-249).
|
||||
pub const WEB_UNIT_FILE_NAME: &str = "maestro-web.service";
|
||||
@@ -27,7 +28,7 @@ pub fn user_unit_path_for(unit_name: &str) -> Option<PathBuf> {
|
||||
Some(base.join("systemd").join("user").join(unit_name))
|
||||
}
|
||||
|
||||
/// Convenience: path for `maestrod.service`. Kept as a free function so
|
||||
/// Convenience: path for `maestro.service`. Kept as a free function so
|
||||
/// existing callers don't need to thread the unit name around.
|
||||
pub fn user_unit_path() -> Option<PathBuf> {
|
||||
user_unit_path_for(UNIT_FILE_NAME)
|
||||
@@ -35,23 +36,32 @@ pub fn user_unit_path() -> Option<PathBuf> {
|
||||
|
||||
/// Convenience: path for `maestro-web.service` (NAP-249).
|
||||
pub fn user_web_unit_path() -> Option<PathBuf> {
|
||||
user_unit_path_for(WEB_UNIT_FILE_NAME)
|
||||
user_web_unit_path_for(WEB_UNIT_FILE_NAME)
|
||||
}
|
||||
|
||||
/// Render the daemon unit. `exec_start` is the absolute path to `maestrod` —
|
||||
/// we default to `~/.cargo/bin/maestrod` because that's where `cargo install`
|
||||
fn user_web_unit_path_for(unit_name: &str) -> Option<PathBuf> {
|
||||
user_unit_path_for(unit_name)
|
||||
}
|
||||
|
||||
/// Render the daemon unit. `exec_start` is the absolute path to `maestro` —
|
||||
/// we default to `~/.cargo/bin/maestro` because that's where `cargo install`
|
||||
/// lands binaries; systemd doesn't inherit the user's PATH, so we can't rely
|
||||
/// on a bare `maestrod`.
|
||||
/// on a bare `maestro`. The unit runs `maestro daemon` (NAP-245).
|
||||
pub fn render_user_unit(exec_start: &Path) -> String {
|
||||
format!(
|
||||
"[Unit]\n\
|
||||
Description=Maestro per-host daemon (maestrod)\n\
|
||||
Description=Maestro per-host daemon\n\
|
||||
Documentation=https://git.naps.pt/naps62/maestro\n\
|
||||
After=network-online.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
ExecStart={exec}\n\
|
||||
ExecStart={exec} daemon\n\
|
||||
# Signal only the daemon on stop/restart, not its whole cgroup —\n\
|
||||
# otherwise systemd kills the tmux server (and every session) the\n\
|
||||
# daemon spawned, defeating tmux persistence. recover() re-adopts the\n\
|
||||
# surviving sessions on the next start (NAP-330).\n\
|
||||
KillMode=process\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=5\n\
|
||||
\n\
|
||||
@@ -61,7 +71,7 @@ pub fn render_user_unit(exec_start: &Path) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the `maestro-web` user unit (NAP-249). Orders after `maestrod`
|
||||
/// Render the `maestro-web` user unit (NAP-249). Orders after `maestro.service`
|
||||
/// so the proxy doesn't return 502s on startup; `Wants=` keeps it best-effort
|
||||
/// — operators with daemon-less hosts can still install web alone if they
|
||||
/// later remove the dependency.
|
||||
@@ -70,8 +80,8 @@ pub fn render_web_unit(exec_start: &Path) -> String {
|
||||
"[Unit]\n\
|
||||
Description=Maestro web UI (maestro-web)\n\
|
||||
Documentation=https://git.naps.pt/naps62/maestro\n\
|
||||
After=network-online.target maestrod.service\n\
|
||||
Wants=maestrod.service\n\
|
||||
After=network-online.target maestro.service\n\
|
||||
Wants=maestro.service\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
@@ -85,15 +95,10 @@ pub fn render_web_unit(exec_start: &Path) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Default `ExecStart` target: `~/.cargo/bin/maestrod`. `None` if `$HOME` is
|
||||
/// Default `ExecStart` target: `~/.cargo/bin/maestro`. `None` if `$HOME` is
|
||||
/// unresolvable.
|
||||
pub fn default_maestrod_path() -> Option<PathBuf> {
|
||||
Some(
|
||||
dirs::home_dir()?
|
||||
.join(".cargo")
|
||||
.join("bin")
|
||||
.join("maestrod"),
|
||||
)
|
||||
pub fn default_maestro_path() -> Option<PathBuf> {
|
||||
Some(dirs::home_dir()?.join(".cargo").join("bin").join("maestro"))
|
||||
}
|
||||
|
||||
/// Default `ExecStart` target for the web unit: `~/.cargo/bin/maestro-web`.
|
||||
@@ -127,8 +132,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unit_renders_with_absolute_exec_start() {
|
||||
let body = render_user_unit(Path::new("/home/alice/.cargo/bin/maestrod"));
|
||||
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestrod"));
|
||||
let body = render_user_unit(Path::new("/home/alice/.cargo/bin/maestro"));
|
||||
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro daemon"));
|
||||
assert!(body.contains("[Unit]"));
|
||||
assert!(body.contains("[Service]"));
|
||||
assert!(body.contains("[Install]"));
|
||||
@@ -136,6 +141,38 @@ mod tests {
|
||||
assert!(body.contains("Restart=on-failure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_sets_killmode_process_so_restart_keeps_sessions() {
|
||||
// NAP-330: without KillMode=process, `systemctl restart` SIGKILLs the
|
||||
// daemon's whole cgroup — including the tmux server and every session.
|
||||
let body = render_user_unit(Path::new("/x/maestro"));
|
||||
assert!(
|
||||
body.contains("\nKillMode=process\n"),
|
||||
"daemon unit must set KillMode=process; got:\n{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_unit_does_not_set_killmode() {
|
||||
// The web unit spawns no tmux, so it has no cgroup-kill problem and
|
||||
// shouldn't carry the override.
|
||||
let body = render_web_unit(Path::new("/x/maestro-web"));
|
||||
assert!(
|
||||
!body.contains("KillMode"),
|
||||
"web unit needs no KillMode; got:\n{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_has_no_execreload() {
|
||||
// NAP-339 removed the daemon's SIGHUP handler (it only ever reloaded
|
||||
// the now-deleted `[daemon.policy]`). Without a handler, SIGHUP's
|
||||
// default disposition is to terminate, so the unit must NOT wire
|
||||
// `systemctl reload` to it — that would kill the daemon.
|
||||
let body = render_user_unit(Path::new("/x/maestro"));
|
||||
assert!(!body.contains("ExecReload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_ends_with_newline() {
|
||||
let body = render_user_unit(Path::new("/x"));
|
||||
@@ -145,7 +182,7 @@ mod tests {
|
||||
#[test]
|
||||
fn user_unit_path_points_under_config_dir() {
|
||||
let p = user_unit_path().expect("config dir resolvable in test env");
|
||||
assert!(p.ends_with("systemd/user/maestrod.service"), "got {p:?}");
|
||||
assert!(p.ends_with("systemd/user/maestro.service"), "got {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -158,12 +195,18 @@ mod tests {
|
||||
fn web_unit_renders_with_dependency_on_daemon() {
|
||||
let body = render_web_unit(Path::new("/home/alice/.cargo/bin/maestro-web"));
|
||||
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro-web"));
|
||||
assert!(body.contains("After=network-online.target maestrod.service"));
|
||||
assert!(body.contains("Wants=maestrod.service"));
|
||||
assert!(body.contains("After=network-online.target maestro.service"));
|
||||
assert!(body.contains("Wants=maestro.service"));
|
||||
assert!(body.contains("WantedBy=default.target"));
|
||||
assert!(body.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_maestro_path_under_cargo_bin() {
|
||||
let p = default_maestro_path().expect("HOME resolvable in test env");
|
||||
assert!(p.ends_with(".cargo/bin/maestro"), "got {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_maestro_web_path_under_cargo_bin() {
|
||||
let p = default_maestro_web_path().expect("HOME resolvable in test env");
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "maestro-names"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Session-handle generator: musical adjective-noun wordlists"
|
||||
|
||||
[dependencies]
|
||||
rand.workspace = true
|
||||
@@ -0,0 +1,96 @@
|
||||
//! The one home of the session-handle wordlists (#53).
|
||||
//!
|
||||
//! Previously duplicated in `web/src/names.ts`; every client now gets handles
|
||||
//! from here, served by `GET /api/names`.
|
||||
|
||||
/// 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",
|
||||
];
|
||||
|
||||
/// Upper bound for a `GET /api/names?count=N` batch.
|
||||
pub const MAX_BATCH: usize = 100;
|
||||
|
||||
/// A fresh `adjective-noun` handle, e.g. `allegro-cello`.
|
||||
pub fn generate() -> String {
|
||||
use rand::seq::SliceRandom;
|
||||
let mut rng = rand::thread_rng();
|
||||
format!(
|
||||
"{}-{}",
|
||||
ADJECTIVES.choose(&mut rng).unwrap(),
|
||||
NOUNS.choose(&mut rng).unwrap()
|
||||
)
|
||||
}
|
||||
|
||||
/// `count` fresh handles (the caller clamps; this only trusts its input).
|
||||
pub fn generate_batch(count: usize) -> Vec<String> {
|
||||
(0..count).map(|_| generate()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_name_is_adjective_dash_noun() {
|
||||
let name = generate();
|
||||
let (adj, noun) = name.split_once('-').expect("name contains a dash");
|
||||
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES");
|
||||
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_returns_the_requested_count() {
|
||||
assert_eq!(generate_batch(1).len(), 1);
|
||||
assert_eq!(generate_batch(50).len(), 50);
|
||||
for name in generate_batch(10) {
|
||||
let (adj, noun) = name.split_once('-').unwrap();
|
||||
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerolls_can_differ() {
|
||||
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
|
||||
let first = generate();
|
||||
let differs = (0..20).any(|_| generate() != first);
|
||||
assert!(differs);
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,22 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Shared wire types for Maestro daemon/registry/client communication"
|
||||
description = "Shared wire types for Maestro daemon/client communication"
|
||||
|
||||
[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]
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid.workspace = true
|
||||
reqwest = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! 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, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||
KillOutcome, Project, RecentDirs, RetainedWorktree, 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
|
||||
}
|
||||
|
||||
/// Kill a session. With `force`, the daemon overrides the kill refusal
|
||||
/// (#79) and retains the worktree instead of removing it (#81); the
|
||||
/// outcome then names what was kept and where.
|
||||
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<KillOutcome, ClientError> {
|
||||
let query: &[(&str, &str)] = if force {
|
||||
&[(routes::FORCE_QUERY, "true")]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let resp = self
|
||||
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
|
||||
.await?;
|
||||
let resp = check(resp).await?;
|
||||
// An unforced kill keeps its historical `204 No Content`.
|
||||
if resp.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Ok(KillOutcome { retained: None });
|
||||
}
|
||||
json(resp).await
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||
self.get(routes::PROJECTS, &[]).await
|
||||
}
|
||||
|
||||
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
|
||||
self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)])
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/worktrees` (#80): every retained worktree with its in-the-way
|
||||
/// breakdown as of this read.
|
||||
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
|
||||
self.get(routes::WORKTREES, &[]).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The
|
||||
/// daemon removes the directory and drops the store entry; the branch is
|
||||
/// never deleted.
|
||||
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
|
||||
let resp = self
|
||||
.send(Method::DELETE, &routes::worktree(id), &[], None::<&()>)
|
||||
.await?;
|
||||
check(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- 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?)
|
||||
}
|
||||
+563
-762
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
||||
//! 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` discovered git repositories and worktrees, newest activity first.
|
||||
pub const PROJECTS: &str = "/api/projects";
|
||||
/// `GET` the enclosing repository of a path. Takes a `path` query param.
|
||||
pub const GITROOT: &str = "/api/gitroot";
|
||||
/// `GET` one repository's local branches. Takes a `path` query param.
|
||||
pub const BRANCHES: &str = "/api/branches";
|
||||
/// `GET` a batch of generated session handles (#53). Takes a `count` query
|
||||
/// param (default 1, capped at [`maestro_names`]' batch cap).
|
||||
pub const NAMES: &str = "/api/names";
|
||||
/// `GET` retained worktrees (#80): what the store holds, with the in-the-way
|
||||
/// breakdown recomputed on read.
|
||||
pub const WORKTREES: &str = "/api/worktrees";
|
||||
/// `WS` catalog snapshot + session deltas.
|
||||
pub const EVENTS: &str = "/api/events";
|
||||
|
||||
/// Name of the query parameter [`DIRS`], [`GITROOT`], and [`BRANCHES`] take.
|
||||
pub const PATH_QUERY: &str = "path";
|
||||
|
||||
/// Name of the query parameter [`NAMES`] takes.
|
||||
pub const COUNT_QUERY: &str = "count";
|
||||
|
||||
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
|
||||
/// `force=true` overrides the kill refusal and retains the worktree.
|
||||
pub const FORCE_QUERY: &str = "force";
|
||||
|
||||
/// `DELETE /api/sessions/:id`.
|
||||
pub fn session(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}")
|
||||
}
|
||||
|
||||
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
|
||||
pub fn worktree(id: Uuid) -> String {
|
||||
format!("{WORKTREES}/{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";
|
||||
pub const WORKTREE: &str = "/api/worktrees/: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!(
|
||||
worktree(id()),
|
||||
"/api/worktrees/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())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::WORKTREE.trim_end_matches(":id"),
|
||||
worktree(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_route_sits_under_the_api_prefix() {
|
||||
for path in [
|
||||
SESSIONS,
|
||||
INFO,
|
||||
DIRS,
|
||||
RECENT_DIRS,
|
||||
PROJECTS,
|
||||
GITROOT,
|
||||
BRANCHES,
|
||||
NAMES,
|
||||
WORKTREES,
|
||||
EVENTS,
|
||||
] {
|
||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||
}
|
||||
assert!(attach(id()).starts_with(API_PREFIX));
|
||||
}
|
||||
}
|
||||
@@ -1,40 +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"
|
||||
|
||||
[[bin]]
|
||||
name = "maestro-registry"
|
||||
path = "src/main.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
|
||||
rust-embed.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,163 +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 serde::{Deserialize, Serialize};
|
||||
|
||||
/// Name of the auth cookie surfaced to web clients.
|
||||
pub const COOKIE_NAME: &str = "maestro_session";
|
||||
|
||||
/// 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;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CookieClaims {
|
||||
/// 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 {
|
||||
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`.
|
||||
pub fn verify_cookie(secret: &str, value: &str) -> bool {
|
||||
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
/// Find our cookie inside an inbound `Cookie:` header. Header may contain
|
||||
/// many `name=value` pairs separated by `;`.
|
||||
pub fn extract_cookie<'a>(header_value: &'a str, name: &str) -> Option<&'a str> {
|
||||
for part in header_value.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(eq) = part.find('=') {
|
||||
let (k, v) = part.split_at(eq);
|
||||
if k == name {
|
||||
return Some(&v[1..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_cookie_finds_named_value() {
|
||||
let h = "foo=1; maestro_session=abc.def.ghi; bar=2";
|
||||
assert_eq!(extract_cookie(h, "maestro_session"), Some("abc.def.ghi"));
|
||||
assert_eq!(extract_cookie(h, "foo"), Some("1"));
|
||||
assert_eq!(extract_cookie(h, "missing"), None);
|
||||
}
|
||||
|
||||
#[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,200 +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, 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, Session};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::events::events_ws;
|
||||
use crate::jwt::issue_attach_jwt;
|
||||
use crate::state::{Registry, RegistryError, RPC_TIMEOUT};
|
||||
use crate::web_assets;
|
||||
|
||||
/// 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 {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/assets/*file", get(asset))
|
||||
.route("/api/daemons", get(list_daemons))
|
||||
.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 index() -> Response {
|
||||
web_assets::serve_index()
|
||||
}
|
||||
|
||||
async fn asset(Path(file): Path<String>) -> Response {
|
||||
web_assets::serve_asset(&file)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,235 +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) = match hello {
|
||||
DaemonOutbound::Hello {
|
||||
daemon_id,
|
||||
hostname,
|
||||
version,
|
||||
token,
|
||||
daemon_url,
|
||||
} => (daemon_id, hostname, version, token, daemon_url),
|
||||
_ => {
|
||||
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);
|
||||
registry
|
||||
.register(
|
||||
daemon_id.clone(),
|
||||
hostname,
|
||||
version,
|
||||
daemon_url,
|
||||
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).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).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) => {
|
||||
tracing::warn!(%daemon_id, error = %e, "malformed message");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
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_id,
|
||||
private_addr,
|
||||
} => {
|
||||
// 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_id, private_addr)
|
||||
.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::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)");
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,100 +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. Plain string for
|
||||
/// now — once the registry gets a per-instance identity, plumb that in here.
|
||||
pub const REGISTRY_ISSUER: &str = "maestro-registry";
|
||||
|
||||
/// 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, `aud == self.daemon_id`, `exp > now`, and
|
||||
/// `sub in allow_inbound` before accepting the broker-attach.
|
||||
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,32 +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 server;
|
||||
pub mod state;
|
||||
pub mod token;
|
||||
pub mod web_assets;
|
||||
|
||||
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,59 +0,0 @@
|
||||
//! `maestro-registry` — the Maestro always-on registry binary.
|
||||
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use maestro_registry::{default_config_path, load_config, router, Registry};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
|
||||
match run().await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "maestro-registry failed");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn Error>> {
|
||||
let config_path: PathBuf = match std::env::var_os(maestro_registry::CONFIG_PATH_ENV) {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => default_config_path()?,
|
||||
};
|
||||
let env_token = std::env::var(maestro_registry::ENV_TOKEN).ok();
|
||||
let env_bind = std::env::var(maestro_registry::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))
|
||||
}
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
//! In-memory registry state shared across all daemon connections.
|
||||
//!
|
||||
//! The state holds:
|
||||
//!
|
||||
//! - one [`DaemonEntry`] per connected daemon, keyed by `daemon_id`
|
||||
//! - one entry in the session catalog per `(daemon_id, session_id)` pair
|
||||
//! - one pending-reply oneshot per outstanding R→D RPC
|
||||
//!
|
||||
//! Concurrent access is guarded by a single `Mutex` — the registry handles a
|
||||
//! handful of daemons and these critical sections are tiny.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use maestro_protocol::{
|
||||
AttachCandidates, CreateSessionRequest, DaemonInbound, DaemonInfo, ErrorInfo,
|
||||
PerimeterCandidate, RegistryEvent, ReplyTo, RpcResult, Session,
|
||||
};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jwt::issue_pair_attach_jwt;
|
||||
|
||||
/// Capacity of the registry-wide broadcast channel that feeds
|
||||
/// `WS /api/events` subscribers. Subscribers that fall this far behind get a
|
||||
/// `Lagged` event and are expected to resync via `GET /api/sessions`.
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
/// How long a daemon can go without sending us *any* message before it is
|
||||
/// considered offline and its sessions are dropped from the catalog.
|
||||
pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long we wait for a `CreateSessionResult` / `KillSessionResult` after
|
||||
/// sending the matching R→D request. Spec §"Wire protocol (D↔R)".
|
||||
pub const RPC_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RegistryError {
|
||||
#[error("daemon `{0}` is not connected")]
|
||||
DaemonNotConnected(String),
|
||||
#[error("daemon outbound channel closed before send")]
|
||||
DaemonGone,
|
||||
#[error("rpc reply channel closed before reply arrived")]
|
||||
ReplyChannelClosed,
|
||||
#[error("rpc timed out after {0:?}")]
|
||||
Timeout(Duration),
|
||||
}
|
||||
|
||||
/// Distinct error type for RPC failures bubbled up by the daemon. Separated
|
||||
/// from [`RegistryError`] so callers can distinguish transport from daemon-
|
||||
/// reported faults.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("daemon rpc failed: {0:?}")]
|
||||
pub struct RpcError(pub ErrorInfo);
|
||||
|
||||
/// Public, read-only view of a single connected daemon. Convertible to the
|
||||
/// protocol-level `DaemonInfo` for inclusion in HTTP responses and events.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DaemonView {
|
||||
pub daemon_id: String,
|
||||
pub hostname: String,
|
||||
pub version: String,
|
||||
pub daemon_url: String,
|
||||
}
|
||||
|
||||
impl From<DaemonView> for DaemonInfo {
|
||||
fn from(v: DaemonView) -> Self {
|
||||
Self {
|
||||
daemon_id: v.daemon_id,
|
||||
hostname: v.hostname,
|
||||
version: v.version,
|
||||
daemon_url: v.daemon_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared, cloneable registry handle.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Registry {
|
||||
inner: Arc<Mutex<State>>,
|
||||
events: broadcast::Sender<RegistryEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
daemons: HashMap<String, DaemonEntry>,
|
||||
/// `(daemon_id, session_id) -> Session`.
|
||||
sessions: BTreeMap<(String, Uuid), Session>,
|
||||
pending_create: HashMap<ReplyTo, oneshot::Sender<RpcResult<Session>>>,
|
||||
pending_kill: HashMap<ReplyTo, oneshot::Sender<RpcResult<()>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DaemonEntry {
|
||||
hostname: String,
|
||||
version: String,
|
||||
daemon_url: String,
|
||||
outbound: mpsc::Sender<DaemonInbound>,
|
||||
/// Latest perimeter advertised by this daemon in its heartbeats.
|
||||
/// `None` until the daemon advertises one (NAP-247 T2 dial target).
|
||||
perimeter_id: Option<String>,
|
||||
/// Latest private address advertised on the declared perimeter.
|
||||
private_addr: Option<String>,
|
||||
}
|
||||
|
||||
impl DaemonEntry {
|
||||
fn to_view(&self, daemon_id: &str) -> DaemonView {
|
||||
DaemonView {
|
||||
daemon_id: daemon_id.to_string(),
|
||||
hostname: self.hostname.clone(),
|
||||
version: self.version.clone(),
|
||||
daemon_url: self.daemon_url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn new() -> Self {
|
||||
let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(State::default())),
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to the registry's broadcast event stream. The subscriber gets
|
||||
/// a one-shot `Snapshot` of the current catalog first, then live deltas.
|
||||
pub async fn subscribe(&self) -> (RegistryEvent, broadcast::Receiver<RegistryEvent>) {
|
||||
let state = self.inner.lock().await;
|
||||
let snapshot = RegistryEvent::Snapshot {
|
||||
daemons: state
|
||||
.daemons
|
||||
.iter()
|
||||
.map(|(id, e)| e.to_view(id).into())
|
||||
.collect(),
|
||||
sessions: state.sessions.values().cloned().collect(),
|
||||
};
|
||||
let rx = self.events.subscribe();
|
||||
(snapshot, rx)
|
||||
}
|
||||
|
||||
fn broadcast(&self, event: RegistryEvent) {
|
||||
// Send returns Err only when there are no subscribers; that's not a
|
||||
// bug, so swallow.
|
||||
let _ = self.events.send(event);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Connection lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Register a freshly-authenticated daemon. Replaces any previous entry
|
||||
/// with the same `daemon_id` (and drops its sessions), matching the
|
||||
/// spec's "re-registration restores catalog" expectation.
|
||||
pub async fn register(
|
||||
&self,
|
||||
daemon_id: String,
|
||||
hostname: String,
|
||||
version: String,
|
||||
daemon_url: String,
|
||||
outbound: mpsc::Sender<DaemonInbound>,
|
||||
) {
|
||||
let mut state = self.inner.lock().await;
|
||||
let removed_sessions = state.drop_daemon_sessions(&daemon_id);
|
||||
state.daemons.insert(
|
||||
daemon_id.clone(),
|
||||
DaemonEntry {
|
||||
hostname: hostname.clone(),
|
||||
version: version.clone(),
|
||||
daemon_url: daemon_url.clone(),
|
||||
outbound,
|
||||
perimeter_id: None,
|
||||
private_addr: None,
|
||||
},
|
||||
);
|
||||
drop(state);
|
||||
for id in removed_sessions {
|
||||
self.broadcast(RegistryEvent::SessionRemoved {
|
||||
daemon_id: daemon_id.clone(),
|
||||
id,
|
||||
});
|
||||
}
|
||||
self.broadcast(RegistryEvent::DaemonAdded {
|
||||
daemon: DaemonInfo {
|
||||
daemon_id,
|
||||
hostname,
|
||||
version,
|
||||
daemon_url,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove the daemon and any sessions it owned. Also fails any pending
|
||||
/// RPCs that were sent to it.
|
||||
pub async fn deregister(&self, daemon_id: &str) {
|
||||
let removed_sessions;
|
||||
let had_daemon;
|
||||
{
|
||||
let mut state = self.inner.lock().await;
|
||||
had_daemon = state.daemons.remove(daemon_id).is_some();
|
||||
removed_sessions = state.drop_daemon_sessions(daemon_id);
|
||||
}
|
||||
for id in removed_sessions {
|
||||
self.broadcast(RegistryEvent::SessionRemoved {
|
||||
daemon_id: daemon_id.to_string(),
|
||||
id,
|
||||
});
|
||||
}
|
||||
if had_daemon {
|
||||
self.broadcast(RegistryEvent::DaemonRemoved {
|
||||
daemon_id: daemon_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Catalog mutations driven by inbound D→R messages
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub async fn apply_snapshot(&self, daemon_id: &str, sessions: Vec<Session>) {
|
||||
let (removed, added) = {
|
||||
let mut state = self.inner.lock().await;
|
||||
let removed = state.drop_daemon_sessions(daemon_id);
|
||||
let mut added = Vec::with_capacity(sessions.len());
|
||||
for s in sessions {
|
||||
added.push(s.clone());
|
||||
state.sessions.insert((daemon_id.to_string(), s.id), s);
|
||||
}
|
||||
(removed, added)
|
||||
};
|
||||
for id in removed {
|
||||
self.broadcast(RegistryEvent::SessionRemoved {
|
||||
daemon_id: daemon_id.to_string(),
|
||||
id,
|
||||
});
|
||||
}
|
||||
for s in added {
|
||||
self.broadcast(RegistryEvent::SessionAdded { session: s });
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_session(&self, daemon_id: &str, session: Session) {
|
||||
{
|
||||
let mut state = self.inner.lock().await;
|
||||
state
|
||||
.sessions
|
||||
.insert((daemon_id.to_string(), session.id), session.clone());
|
||||
}
|
||||
self.broadcast(RegistryEvent::SessionAdded { session });
|
||||
}
|
||||
|
||||
pub async fn remove_session(&self, daemon_id: &str, id: Uuid) {
|
||||
let removed = {
|
||||
let mut state = self.inner.lock().await;
|
||||
state.sessions.remove(&(daemon_id.to_string(), id))
|
||||
};
|
||||
if removed.is_some() {
|
||||
self.broadcast(RegistryEvent::SessionRemoved {
|
||||
daemon_id: daemon_id.to_string(),
|
||||
id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_session(&self, daemon_id: &str, session: Session) {
|
||||
{
|
||||
let mut state = self.inner.lock().await;
|
||||
state
|
||||
.sessions
|
||||
.insert((daemon_id.to_string(), session.id), session.clone());
|
||||
}
|
||||
self.broadcast(RegistryEvent::SessionUpdated { session });
|
||||
}
|
||||
|
||||
/// Look up the daemon that owns the given session id.
|
||||
pub async fn daemon_for_session(&self, session_id: Uuid) -> Option<String> {
|
||||
let state = self.inner.lock().await;
|
||||
state
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|((_, id), _)| *id == session_id)
|
||||
.map(|((d, _), _)| d.clone())
|
||||
}
|
||||
|
||||
/// Resolve a daemon's connect URL (the operator-advertised value from its
|
||||
/// Hello frame).
|
||||
pub async fn daemon_url(&self, daemon_id: &str) -> Option<String> {
|
||||
let state = self.inner.lock().await;
|
||||
state.daemons.get(daemon_id).map(|e| e.daemon_url.clone())
|
||||
}
|
||||
|
||||
/// Update a daemon's advertised perimeter from its heartbeat. Called by
|
||||
/// the WS handler on every `Heartbeat` frame (NAP-247).
|
||||
pub async fn update_perimeter(
|
||||
&self,
|
||||
daemon_id: &str,
|
||||
perimeter_id: Option<String>,
|
||||
private_addr: Option<String>,
|
||||
) {
|
||||
let mut state = self.inner.lock().await;
|
||||
if let Some(entry) = state.daemons.get_mut(daemon_id) {
|
||||
entry.perimeter_id = perimeter_id;
|
||||
entry.private_addr = private_addr;
|
||||
}
|
||||
}
|
||||
|
||||
/// `(perimeter_id, private_addr)` last advertised by this daemon, when
|
||||
/// both are set. The broker uses this to assemble `AttachCandidates` for
|
||||
/// the T2 transport (NAP-247).
|
||||
pub async fn perimeter_for(&self, daemon_id: &str) -> Option<(String, String)> {
|
||||
let state = self.inner.lock().await;
|
||||
state.daemons.get(daemon_id).and_then(|e| {
|
||||
match (e.perimeter_id.clone(), e.private_addr.clone()) {
|
||||
(Some(pid), Some(addr)) => Some((pid, addr)),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Handle a `ResolveAttach` from a home daemon (NAP-247). Look up which
|
||||
/// daemon owns `session_id`, mint a pair-scoped JWT for the home → target
|
||||
/// pair, and reply with `AttachCandidates`. Errors when the session is
|
||||
/// unknown or its owning daemon has gone offline.
|
||||
pub async fn resolve_attach(
|
||||
&self,
|
||||
home_daemon_id: &str,
|
||||
reply_to: ReplyTo,
|
||||
session_id: Uuid,
|
||||
shared_secret: &str,
|
||||
) {
|
||||
let result = self
|
||||
.build_attach_candidates(home_daemon_id, session_id, shared_secret)
|
||||
.await;
|
||||
let frame = DaemonInbound::ResolveAttachResult { reply_to, result };
|
||||
let outbound = {
|
||||
let state = self.inner.lock().await;
|
||||
state
|
||||
.daemons
|
||||
.get(home_daemon_id)
|
||||
.map(|e| e.outbound.clone())
|
||||
};
|
||||
// If the home daemon has gone away mid-flight, drop the response.
|
||||
if let Some(outbound) = outbound {
|
||||
let _ = outbound.send(frame).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_attach_candidates(
|
||||
&self,
|
||||
home_daemon_id: &str,
|
||||
session_id: Uuid,
|
||||
shared_secret: &str,
|
||||
) -> RpcResult<AttachCandidates> {
|
||||
let Some(target) = self.daemon_for_session(session_id).await else {
|
||||
return RpcResult::Err {
|
||||
error: ErrorInfo {
|
||||
code: "unknown_session".into(),
|
||||
message: format!("no daemon owns session {session_id}"),
|
||||
},
|
||||
};
|
||||
};
|
||||
// Self-attach: home daemon already short-circuits this in its own
|
||||
// broker. But if it asks anyway, refuse — `aud != self` won't pass
|
||||
// anyway and we'd just be wasting a JWT.
|
||||
if target == home_daemon_id {
|
||||
return RpcResult::Err {
|
||||
error: ErrorInfo {
|
||||
code: "self_target".into(),
|
||||
message: "home daemon owns this session; short-circuit locally".into(),
|
||||
},
|
||||
};
|
||||
}
|
||||
let jwt = match issue_pair_attach_jwt(shared_secret, home_daemon_id, &target, session_id) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return RpcResult::Err {
|
||||
error: ErrorInfo {
|
||||
code: "jwt_failed".into(),
|
||||
message: e.to_string(),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
// T1 (direct WSS) stays None — NAP-235 was canceled.
|
||||
// T2: hand back the target's perimeter so the home daemon can match.
|
||||
// T3 (relay) stays None — NAP-238 spike pending.
|
||||
let perimeter = self
|
||||
.perimeter_for(&target)
|
||||
.await
|
||||
.map(|(pid, addr)| PerimeterCandidate {
|
||||
perimeter_id: pid,
|
||||
private_addr: addr,
|
||||
});
|
||||
RpcResult::Ok {
|
||||
value: AttachCandidates {
|
||||
direct_wss: None,
|
||||
perimeter,
|
||||
relay: None,
|
||||
jwt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a CreateSession RPC reply. Returns true if a waiter was found.
|
||||
pub async fn complete_create(&self, reply_to: ReplyTo, result: RpcResult<Session>) -> bool {
|
||||
let mut state = self.inner.lock().await;
|
||||
match state.pending_create.remove(&reply_to) {
|
||||
Some(tx) => tx.send(result).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a KillSession RPC reply. Returns true if a waiter was found.
|
||||
pub async fn complete_kill(&self, reply_to: ReplyTo, result: RpcResult<()>) -> bool {
|
||||
let mut state = self.inner.lock().await;
|
||||
match state.pending_kill.remove(&reply_to) {
|
||||
Some(tx) => tx.send(result).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Outbound R→D requests (internal API for NAP-215)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Forward a `CreateSession` to the named daemon and wait for the matching
|
||||
/// `CreateSessionResult`. Returns the daemon-reported result on success.
|
||||
pub async fn create_session(
|
||||
&self,
|
||||
daemon_id: &str,
|
||||
request: CreateSessionRequest,
|
||||
) -> Result<Result<Session, RpcError>, RegistryError> {
|
||||
let reply_to = Uuid::new_v4();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let outbound = {
|
||||
let mut state = self.inner.lock().await;
|
||||
state.pending_create.insert(reply_to, tx);
|
||||
state
|
||||
.daemons
|
||||
.get(daemon_id)
|
||||
.ok_or_else(|| RegistryError::DaemonNotConnected(daemon_id.to_string()))?
|
||||
.outbound
|
||||
.clone()
|
||||
};
|
||||
let send_result = outbound
|
||||
.send(DaemonInbound::CreateSession { reply_to, request })
|
||||
.await;
|
||||
if send_result.is_err() {
|
||||
self.inner.lock().await.pending_create.remove(&reply_to);
|
||||
return Err(RegistryError::DaemonGone);
|
||||
}
|
||||
let rpc = await_rpc(rx, reply_to, |s| &mut s.pending_create, &self.inner)
|
||||
.await
|
||||
.map(rpc_into_result)?;
|
||||
// Eagerly mirror the session into the catalog on success — the daemon
|
||||
// also emits `SessionAdded` asynchronously, but the HTTP caller would
|
||||
// otherwise see an empty `GET /api/sessions` immediately after a
|
||||
// successful POST (NAP-246 made the race wider by adding extra
|
||||
// outbound traffic on the daemon control link). `add_session` is
|
||||
// idempotent so a later `SessionAdded` for the same id is a no-op.
|
||||
if let Ok(ref session) = rpc {
|
||||
self.add_session(daemon_id, session.clone()).await;
|
||||
}
|
||||
Ok(rpc)
|
||||
}
|
||||
|
||||
/// Forward a `KillSession` to the named daemon and wait for the matching
|
||||
/// `KillSessionResult`.
|
||||
pub async fn kill_session(
|
||||
&self,
|
||||
daemon_id: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Result<(), RpcError>, RegistryError> {
|
||||
let reply_to = Uuid::new_v4();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let outbound = {
|
||||
let mut state = self.inner.lock().await;
|
||||
state.pending_kill.insert(reply_to, tx);
|
||||
state
|
||||
.daemons
|
||||
.get(daemon_id)
|
||||
.ok_or_else(|| RegistryError::DaemonNotConnected(daemon_id.to_string()))?
|
||||
.outbound
|
||||
.clone()
|
||||
};
|
||||
let send_result = outbound
|
||||
.send(DaemonInbound::KillSession { reply_to, id })
|
||||
.await;
|
||||
if send_result.is_err() {
|
||||
self.inner.lock().await.pending_kill.remove(&reply_to);
|
||||
return Err(RegistryError::DaemonGone);
|
||||
}
|
||||
let rpc = await_rpc(rx, reply_to, |s| &mut s.pending_kill, &self.inner)
|
||||
.await
|
||||
.map(rpc_into_result)?;
|
||||
// Mirror of the create-path eager update: drop the session locally so
|
||||
// subsequent reads see consistent state. The daemon also emits
|
||||
// `SessionRemoved`; `remove_session` is idempotent.
|
||||
if rpc.is_ok() {
|
||||
self.remove_session(daemon_id, id).await;
|
||||
}
|
||||
Ok(rpc)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Read-only views (mostly for tests)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub async fn daemons(&self) -> Vec<DaemonView> {
|
||||
let state = self.inner.lock().await;
|
||||
state.daemons.iter().map(|(id, e)| e.to_view(id)).collect()
|
||||
}
|
||||
|
||||
pub async fn sessions(&self) -> Vec<Session> {
|
||||
let state = self.inner.lock().await;
|
||||
state.sessions.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn sessions_for(&self, daemon_id: &str) -> Vec<Session> {
|
||||
let state = self.inner.lock().await;
|
||||
state
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|((d, _), _)| d == daemon_id)
|
||||
.map(|(_, s)| s.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Registry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Removes every session belonging to `daemon_id`, returning their ids so
|
||||
/// the caller can emit `SessionRemoved` events outside the lock.
|
||||
fn drop_daemon_sessions(&mut self, daemon_id: &str) -> Vec<Uuid> {
|
||||
let mut removed = Vec::new();
|
||||
self.sessions.retain(|(d, id), _| {
|
||||
if d.as_str() == daemon_id {
|
||||
removed.push(*id);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
removed
|
||||
}
|
||||
}
|
||||
|
||||
/// Await `rx` with [`RPC_TIMEOUT`]; on timeout / channel close, also clean up
|
||||
/// the pending map so a late reply doesn't leak.
|
||||
async fn await_rpc<T>(
|
||||
rx: oneshot::Receiver<RpcResult<T>>,
|
||||
reply_to: ReplyTo,
|
||||
pick: impl FnOnce(&mut State) -> &mut HashMap<ReplyTo, oneshot::Sender<RpcResult<T>>>,
|
||||
inner: &Arc<Mutex<State>>,
|
||||
) -> Result<RpcResult<T>, RegistryError> {
|
||||
match tokio::time::timeout(RPC_TIMEOUT, rx).await {
|
||||
Ok(Ok(result)) => Ok(result),
|
||||
Ok(Err(_)) => Err(RegistryError::ReplyChannelClosed),
|
||||
Err(_) => {
|
||||
let mut state = inner.lock().await;
|
||||
pick(&mut state).remove(&reply_to);
|
||||
Err(RegistryError::Timeout(RPC_TIMEOUT))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rpc_into_result<T>(rpc: RpcResult<T>) -> Result<T, RpcError> {
|
||||
match rpc {
|
||||
RpcResult::Ok { value } => Ok(value),
|
||||
RpcResult::Err { error } => Err(RpcError(error)),
|
||||
}
|
||||
}
|
||||
@@ -1,297 +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),
|
||||
#[error("invalid {env} ({input:?}): {source}")]
|
||||
InvalidBindAddr {
|
||||
env: &'static str,
|
||||
input: String,
|
||||
source: std::net::AddrParseError,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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 = raw.parse().map_err(|source| TokenError::InvalidBindAddr {
|
||||
env: ENV_REGISTRY_BIND_ADDR,
|
||||
input: raw.to_string(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
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::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,134 +0,0 @@
|
||||
//! Embedded static assets for the web client bundle.
|
||||
//!
|
||||
//! The Vite build in `web/` writes its output to `web/dist/`; `rust-embed`
|
||||
//! picks that up at `cargo build` time and embeds the files into the binary.
|
||||
//! That keeps deploy artifacts to a single executable.
|
||||
//!
|
||||
//! When the `web/dist` tree is empty (typical for a fresh checkout without
|
||||
//! `pnpm build`), the handlers below fall back to a "build the web bundle
|
||||
//! first" placeholder so the rest of the registry still works for testing.
|
||||
//!
|
||||
//! See NAP-218.
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../web/dist/"]
|
||||
struct WebAssets;
|
||||
|
||||
const FALLBACK_INDEX: &str = "<!doctype html><meta charset=utf-8><title>maestro</title>\
|
||||
<h1>maestro registry</h1>\
|
||||
<p>Web bundle not built. Run <code>pnpm --dir web install</code> \
|
||||
then <code>pnpm --dir web build</code>, then rebuild the registry.</p>";
|
||||
|
||||
fn content_type_for(path: &str) -> &'static str {
|
||||
let ext = path.rsplit('.').next().unwrap_or("");
|
||||
match ext {
|
||||
"html" | "htm" => "text/html; charset=utf-8",
|
||||
"js" | "mjs" => "text/javascript; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"json" => "application/json",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"ico" => "image/x-icon",
|
||||
"woff" => "font/woff",
|
||||
"woff2" => "font/woff2",
|
||||
"map" => "application/json",
|
||||
"txt" => "text/plain; charset=utf-8",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_headers(path: &str) -> &'static str {
|
||||
// Vite emits hashed filenames under /assets/. Those can be cached
|
||||
// aggressively. Everything else (index.html, the favicon, etc.) should
|
||||
// not be cached.
|
||||
if path.starts_with("assets/") {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"no-cache"
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(path: &str) -> Option<Response> {
|
||||
let asset = WebAssets::get(path)?;
|
||||
let body = asset.data.into_owned();
|
||||
Some(
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type_for(path)),
|
||||
(header::CACHE_CONTROL, cache_headers(path)),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Serve `dist/index.html` from the embed; fall back to a placeholder
|
||||
/// pointing the operator at `pnpm build` when the bundle hasn't been built.
|
||||
pub fn serve_index() -> Response {
|
||||
if let Some(resp) = serve("index.html") {
|
||||
return resp;
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
FALLBACK_INDEX,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Serve a hashed asset under `dist/assets/<file>`.
|
||||
pub fn serve_asset(file: &str) -> Response {
|
||||
// Reject anything that tries to escape the `assets/` prefix. `rust-embed`
|
||||
// already won't return parent-directory paths, but we keep the explicit
|
||||
// check so the surface is obvious.
|
||||
if file.contains("..") || file.starts_with('/') {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let path = format!("assets/{file}");
|
||||
match serve(&path) {
|
||||
Some(resp) => resp,
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn content_type_routes_common_extensions() {
|
||||
assert_eq!(content_type_for("foo.js"), "text/javascript; charset=utf-8");
|
||||
assert_eq!(content_type_for("a/b.css"), "text/css; charset=utf-8");
|
||||
assert_eq!(content_type_for("x.svg"), "image/svg+xml");
|
||||
assert_eq!(content_type_for("index.html"), "text/html; charset=utf-8");
|
||||
assert_eq!(content_type_for("noext"), "application/octet-stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_assets_immutable_index_no_cache() {
|
||||
assert!(cache_headers("assets/index-abc.js").contains("immutable"));
|
||||
assert_eq!(cache_headers("index.html"), "no-cache");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_asset_rejects_traversal() {
|
||||
let resp = serve_asset("../secret");
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_index_returns_html() {
|
||||
let resp = serve_index();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert!(ct.starts_with("text/html"));
|
||||
}
|
||||
}
|
||||
@@ -1,385 +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(),
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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,722 +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(),
|
||||
};
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 index_serves_placeholder() {
|
||||
let h = spawn(Duration::from_secs(5)).await;
|
||||
let client = client_with_cookies();
|
||||
let resp = client.get(h.base()).send().await.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
let body = resp.text().await.unwrap();
|
||||
assert!(body.contains("maestro"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,366 @@
|
||||
//! 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(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: 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,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "maestro-tui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Terminal UI client over the local maestro daemon's `/api/*` surface."
|
||||
|
||||
[dependencies]
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
crossterm.workspace = true
|
||||
futures-util.workspace = true
|
||||
maestro-config.workspace = true
|
||||
maestro-protocol = { workspace = true, features = ["client"] }
|
||||
ratatui.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||
tokio-tungstenite.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
vt100.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
maestro-testing.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt-multi-thread", "net"] }
|
||||
@@ -0,0 +1,240 @@
|
||||
//! `Action` — the closed list of verbs the TUI knows about, plus the canonical
|
||||
//! string form used by the keymap config.
|
||||
//!
|
||||
//! Every binding in `[tui.keymap.normal]` / `[tui.keymap.insert]` resolves to
|
||||
//! one of these variants. The string form is what the user types in the TOML;
|
||||
//! the enum is what the dispatcher matches on.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Geometric direction for focus motion and resize. Used inside [`Action`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Direction {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// Closed set of verbs the TUI dispatch loop understands. New verbs require a
|
||||
/// matching arm in `Action::parse`, the `Display` impl, and the dispatcher.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Action {
|
||||
/// No-op. Bind to a key to suppress a default binding.
|
||||
Noop,
|
||||
|
||||
// Mode
|
||||
EnterInsert,
|
||||
LeaveInsert,
|
||||
|
||||
// Focus
|
||||
FocusSidebar,
|
||||
FocusPane(Direction),
|
||||
|
||||
// Sidebar highlight movement
|
||||
SidebarNext,
|
||||
SidebarPrev,
|
||||
|
||||
// Catalog actions on the highlighted sidebar row
|
||||
AttachHighlighted,
|
||||
ToggleAttachHighlighted,
|
||||
NewDraftPane,
|
||||
/// Fast-forward spawn (#67): spawn immediately with every default
|
||||
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||
FastForwardSpawn,
|
||||
KillHighlighted,
|
||||
/// Open the retained-worktrees view (#85).
|
||||
ShowWorktrees,
|
||||
|
||||
// Tabs
|
||||
NewTab,
|
||||
CloseTab,
|
||||
NextTab,
|
||||
PrevTab,
|
||||
GotoTab(u8),
|
||||
|
||||
// Chrome
|
||||
ToggleSidebar,
|
||||
ShowHelp,
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum ActionParseError {
|
||||
#[error("unknown action {0:?}")]
|
||||
Unknown(String),
|
||||
#[error("bad arg for {action}: {detail}")]
|
||||
BadArg { action: String, detail: String },
|
||||
#[error("malformed action string {0:?}")]
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// Parse a canonical action string like `"EnterInsert"`, `"FocusPane(Left)"`,
|
||||
/// or `"GotoTab(3)"`.
|
||||
pub fn parse(s: &str) -> Result<Self, ActionParseError> {
|
||||
let s = s.trim();
|
||||
match s {
|
||||
"Noop" => return Ok(Action::Noop),
|
||||
"EnterInsert" => return Ok(Action::EnterInsert),
|
||||
"LeaveInsert" => return Ok(Action::LeaveInsert),
|
||||
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
||||
"SidebarNext" => return Ok(Action::SidebarNext),
|
||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
||||
"NewTab" => return Ok(Action::NewTab),
|
||||
"CloseTab" => return Ok(Action::CloseTab),
|
||||
"NextTab" => return Ok(Action::NextTab),
|
||||
"PrevTab" => return Ok(Action::PrevTab),
|
||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||
"Quit" => return Ok(Action::Quit),
|
||||
_ => {}
|
||||
}
|
||||
// Arg actions: Name(arg)
|
||||
let (name, rest) = s
|
||||
.split_once('(')
|
||||
.ok_or_else(|| ActionParseError::Unknown(s.to_string()))?;
|
||||
let arg = rest
|
||||
.strip_suffix(')')
|
||||
.ok_or_else(|| ActionParseError::Malformed(s.to_string()))?;
|
||||
match name {
|
||||
"FocusPane" => {
|
||||
let dir = parse_direction(arg).map_err(|d| ActionParseError::BadArg {
|
||||
action: name.to_string(),
|
||||
detail: d,
|
||||
})?;
|
||||
Ok(Action::FocusPane(dir))
|
||||
}
|
||||
"GotoTab" => {
|
||||
let n: u8 = arg.parse().map_err(|_| ActionParseError::BadArg {
|
||||
action: name.to_string(),
|
||||
detail: format!("expected u8 0..=255, got {arg:?}"),
|
||||
})?;
|
||||
Ok(Action::GotoTab(n))
|
||||
}
|
||||
_ => Err(ActionParseError::Unknown(name.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_direction(s: &str) -> Result<Direction, String> {
|
||||
match s {
|
||||
"Left" => Ok(Direction::Left),
|
||||
"Right" => Ok(Direction::Right),
|
||||
"Up" => Ok(Direction::Up),
|
||||
"Down" => Ok(Direction::Down),
|
||||
other => Err(format!("expected Left/Right/Up/Down, got {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Action {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
|
||||
Action::GotoTab(n) => write!(f, "GotoTab({n})"),
|
||||
other => write!(f, "{other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_no_arg_actions() {
|
||||
assert_eq!(Action::parse("EnterInsert").unwrap(), Action::EnterInsert);
|
||||
assert_eq!(Action::parse("Quit").unwrap(), Action::Quit);
|
||||
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_focus_pane_directions() {
|
||||
assert_eq!(
|
||||
Action::parse("FocusPane(Left)").unwrap(),
|
||||
Action::FocusPane(Direction::Left),
|
||||
);
|
||||
assert_eq!(
|
||||
Action::parse("FocusPane(Down)").unwrap(),
|
||||
Action::FocusPane(Direction::Down),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_goto_tab() {
|
||||
assert_eq!(Action::parse("GotoTab(3)").unwrap(), Action::GotoTab(3));
|
||||
assert_eq!(Action::parse("GotoTab(0)").unwrap(), Action::GotoTab(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_action() {
|
||||
let err = Action::parse("Frobnicate").unwrap_err();
|
||||
assert_eq!(err, ActionParseError::Unknown("Frobnicate".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_direction_arg() {
|
||||
let err = Action::parse("FocusPane(Sideways)").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ActionParseError::BadArg { ref action, .. } if action == "FocusPane")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_numeric_tab_arg() {
|
||||
let err = Action::parse("GotoTab(abc)").unwrap_err();
|
||||
assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "GotoTab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_overflow_tab_arg() {
|
||||
let err = Action::parse("GotoTab(9999)").unwrap_err();
|
||||
assert!(matches!(err, ActionParseError::BadArg { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unclosed_paren() {
|
||||
let err = Action::parse("GotoTab(3").unwrap_err();
|
||||
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
|
||||
}
|
||||
|
||||
/// The fast-forward pair sits next to the draft-pane key (#67).
|
||||
#[test]
|
||||
fn fast_forward_spawn_round_trips() {
|
||||
assert_eq!(
|
||||
Action::parse("FastForwardSpawn").unwrap(),
|
||||
Action::FastForwardSpawn
|
||||
);
|
||||
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_round_trips() {
|
||||
for a in [
|
||||
Action::EnterInsert,
|
||||
Action::Quit,
|
||||
Action::NewDraftPane,
|
||||
Action::FocusPane(Direction::Right),
|
||||
Action::GotoTab(7),
|
||||
] {
|
||||
let s = a.to_string();
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
||||
//! `CatalogStore` — the TUI's view of the session list.
|
||||
//!
|
||||
//! Backed entirely by the daemon's `WS /api/events` stream:
|
||||
//!
|
||||
//! 1. The first frame after connect is a [`CatalogEvent::Snapshot`] — that's
|
||||
//! the bootstrap. No separate REST round-trip needed.
|
||||
//! 2. Subsequent frames are deltas (`SessionAdded`, `SessionUpdated`, …) that we
|
||||
//! apply atomically and bump a monotonic version counter.
|
||||
//! 3. On WS disconnect: exponential backoff (1s → 30s, ±25% jitter); the next
|
||||
//! connect resends a fresh `Snapshot` so we converge without needing
|
||||
//! server-side replay.
|
||||
//! 4. `Lagged { skipped }` from the broadcast queue is logged at warn level
|
||||
//! and ignored — the server will re-snapshot on its own bookkeeping; for
|
||||
//! safety we trigger a self-reconnect so the next Snapshot reconciles us.
|
||||
//!
|
||||
//! Cloneable and `Send + Sync` — pass it to the render loop and to a Tokio
|
||||
//! task that drives [`CatalogStore::run_events_loop`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use maestro_protocol::{CatalogEvent, Session};
|
||||
use tokio::sync::Notify;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Snapshot returned by [`CatalogStore::snapshot`]. `version` is monotone — a
|
||||
/// renderer can cheap-compare it across frames to know whether re-render is
|
||||
/// needed.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CatalogSnapshot {
|
||||
pub sessions: Vec<Session>,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct CatalogStore {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
state: RwLock<State>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
sessions: HashMap<Uuid, Session>,
|
||||
/// Bumped on every applied event. Wraps at u64::MAX, which is fine
|
||||
/// because comparisons are equality, not ordering.
|
||||
version: u64,
|
||||
/// True once we've applied at least one Snapshot. Used by
|
||||
/// [`CatalogStore::wait_for_initial_snapshot`].
|
||||
bootstrapped: bool,
|
||||
}
|
||||
|
||||
impl CatalogStore {
|
||||
/// Apply one event atomically. Used by tests directly and by the WS loop.
|
||||
pub fn apply_event(&self, ev: CatalogEvent) {
|
||||
let mut s = self.inner.state.write().expect("catalog rwlock poisoned");
|
||||
match ev {
|
||||
CatalogEvent::Snapshot { sessions } => {
|
||||
s.sessions = sessions.into_iter().map(|x| (x.id, x)).collect();
|
||||
s.bootstrapped = true;
|
||||
}
|
||||
CatalogEvent::SessionAdded { session } | CatalogEvent::SessionUpdated { session } => {
|
||||
s.sessions.insert(session.id, session);
|
||||
}
|
||||
CatalogEvent::SessionRemoved { id } => {
|
||||
s.sessions.remove(&id);
|
||||
}
|
||||
CatalogEvent::Lagged { skipped } => {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
"catalog events stream lagged; resync via reconnect"
|
||||
);
|
||||
// We don't reset state here — the WS loop notices the warn and
|
||||
// can choose to drop+reconnect. Keep state until that happens.
|
||||
}
|
||||
}
|
||||
s.version = s.version.wrapping_add(1);
|
||||
drop(s);
|
||||
self.inner.notify.notify_waiters();
|
||||
}
|
||||
|
||||
/// Cheap O(sessions) read of current state.
|
||||
pub fn snapshot(&self) -> CatalogSnapshot {
|
||||
let s = self.inner.state.read().expect("catalog rwlock poisoned");
|
||||
let mut sessions: Vec<Session> = s.sessions.values().cloned().collect();
|
||||
sessions.sort_by_key(|x| x.id);
|
||||
CatalogSnapshot {
|
||||
sessions,
|
||||
version: s.version,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true once we've applied at least one Snapshot frame.
|
||||
pub fn is_bootstrapped(&self) -> bool {
|
||||
self.inner
|
||||
.state
|
||||
.read()
|
||||
.expect("catalog rwlock poisoned")
|
||||
.bootstrapped
|
||||
}
|
||||
|
||||
/// Wait until the catalog has been changed by something — applied event
|
||||
/// or reconnect Snapshot. The render loop awaits this between frames.
|
||||
pub async fn changed(&self) {
|
||||
self.inner.notify.notified().await;
|
||||
}
|
||||
|
||||
/// Wait until the first Snapshot has landed. Resolves immediately if
|
||||
/// already bootstrapped.
|
||||
pub async fn wait_for_initial_snapshot(&self) {
|
||||
loop {
|
||||
if self.is_bootstrapped() {
|
||||
return;
|
||||
}
|
||||
self.inner.notify.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Long-running task: connect to `ws_url`, parse [`CatalogEvent`] frames,
|
||||
/// apply them. Reconnects with exponential backoff on disconnect. Never
|
||||
/// returns — spawn it on a Tokio task.
|
||||
pub async fn run_events_loop(self, ws_url: String) {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
loop {
|
||||
match tokio_tungstenite::connect_async(&ws_url).await {
|
||||
Ok((ws, _resp)) => {
|
||||
backoff = Duration::from_secs(1);
|
||||
let (_sink, mut stream) = ws.split();
|
||||
while let Some(frame) = stream.next().await {
|
||||
match frame {
|
||||
Ok(Message::Text(text)) => {
|
||||
match serde_json::from_str::<CatalogEvent>(&text) {
|
||||
Ok(ev) => self.apply_event(ev),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "catalog: bad event frame");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = ?e, "catalog: connect failed");
|
||||
}
|
||||
}
|
||||
tracing::debug!(?backoff, "catalog: sleeping before reconnect");
|
||||
tokio::time::sleep(jitter(backoff)).await;
|
||||
backoff = std::cmp::min(backoff.saturating_mul(2), Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ±25 % jitter on the backoff. Cheap LCG seeded from the duration itself —
|
||||
/// avoids touching `rand` for one knob.
|
||||
fn jitter(base: Duration) -> Duration {
|
||||
let ms = base.as_millis() as u64;
|
||||
// Tiny pseudo-random in [0, 1) from system time nanos.
|
||||
let now_nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0) as u64;
|
||||
// ±25 %: scale by [-250, 250] of one quarter ms per ms.
|
||||
let delta = (now_nanos % 500) as i64 - 250;
|
||||
let scaled = (ms as i64).saturating_mul(delta).div_euclid(1000);
|
||||
let total = ms.saturating_add_signed(scaled);
|
||||
Duration::from_millis(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::{Session, SessionStatus};
|
||||
|
||||
fn session(id: u128, command: &str) -> Session {
|
||||
Session {
|
||||
id: Uuid::from_u128(id),
|
||||
command: command.into(),
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_event_replaces_state() {
|
||||
let store = CatalogStore::default();
|
||||
store.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![session(1, "a")],
|
||||
});
|
||||
let snap = store.snapshot();
|
||||
assert_eq!(snap.sessions.len(), 1);
|
||||
assert_eq!(snap.version, 1);
|
||||
assert!(store.is_bootstrapped());
|
||||
|
||||
// A second snapshot wipes out the old state.
|
||||
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
|
||||
let snap = store.snapshot();
|
||||
assert!(snap.sessions.is_empty());
|
||||
assert_eq!(snap.version, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_added_updated_removed() {
|
||||
let store = CatalogStore::default();
|
||||
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
|
||||
store.apply_event(CatalogEvent::SessionAdded {
|
||||
session: session(7, "vim"),
|
||||
});
|
||||
assert_eq!(store.snapshot().sessions.len(), 1);
|
||||
assert_eq!(store.snapshot().sessions[0].command, "vim");
|
||||
|
||||
let mut updated = session(7, "claude");
|
||||
updated.status = SessionStatus::Dead;
|
||||
store.apply_event(CatalogEvent::SessionUpdated { session: updated });
|
||||
let snap = store.snapshot();
|
||||
assert_eq!(snap.sessions[0].command, "claude");
|
||||
assert!(matches!(snap.sessions[0].status, SessionStatus::Dead));
|
||||
|
||||
store.apply_event(CatalogEvent::SessionRemoved {
|
||||
id: Uuid::from_u128(7),
|
||||
});
|
||||
assert!(store.snapshot().sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lagged_event_doesnt_blow_up_state() {
|
||||
let store = CatalogStore::default();
|
||||
store.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![session(1, "a")],
|
||||
});
|
||||
let before = store.snapshot();
|
||||
store.apply_event(CatalogEvent::Lagged { skipped: 42 });
|
||||
let after = store.snapshot();
|
||||
assert_eq!(after.sessions, before.sessions);
|
||||
assert_eq!(after.version, before.version + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_is_sorted() {
|
||||
let store = CatalogStore::default();
|
||||
store.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![session(3, "c"), session(1, "a"), session(2, "b")],
|
||||
});
|
||||
let snap = store.snapshot();
|
||||
let session_ids: Vec<_> = snap.sessions.iter().map(|s| s.id.as_u128()).collect();
|
||||
assert_eq!(session_ids, vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_initial_snapshot_resolves_after_first_event() {
|
||||
let store = CatalogStore::default();
|
||||
let task_store = store.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
task_store.wait_for_initial_snapshot().await;
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
assert!(!handle.is_finished());
|
||||
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
|
||||
// Give the waker a chance.
|
||||
tokio::time::timeout(Duration::from_millis(200), handle)
|
||||
.await
|
||||
.expect("waker did not fire")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_notifies_on_every_apply() {
|
||||
let store = CatalogStore::default();
|
||||
let waiter = {
|
||||
let s = store.clone();
|
||||
tokio::spawn(async move { s.changed().await })
|
||||
};
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
|
||||
tokio::time::timeout(Duration::from_millis(200), waiter)
|
||||
.await
|
||||
.expect("changed did not fire")
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Clap-derive subcommand definition for `maestro tui`.
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct TuiArgs {
|
||||
/// Override the local daemon's HTTP base URL (default `http://127.0.0.1:7070`).
|
||||
#[arg(long, env = "MAESTRO_DAEMON_URL")]
|
||||
pub daemon_url: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct Harness {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
enum Cmd {
|
||||
Tui(TuiArgs),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_with_daemon_url() {
|
||||
let h = Harness::try_parse_from(["maestro", "tui", "--daemon-url", "http://10.0.0.1:7070"])
|
||||
.unwrap();
|
||||
let Cmd::Tui(args) = h.cmd;
|
||||
assert_eq!(args.daemon_url.as_deref(), Some("http://10.0.0.1:7070"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_without_daemon_url() {
|
||||
let h = Harness::try_parse_from(["maestro", "tui"]).unwrap();
|
||||
let Cmd::Tui(args) = h.cmd;
|
||||
assert!(args.daemon_url.is_none());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user