Compare commits

..
Author SHA1 Message Date
Miguel Palhas 059ae86fb8 Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/58-tui-textinput 2026-08-23 19:58:03 +01:00
Miguel Palhas e3996dec76 feat(maestro): wire spawn overlay fields to the text-input widget 2026-08-23 19:57:24 +01:00
Miguel Palhas 503f01ae26 merge: web inline draft pane (#57) 2026-08-23 19:54:50 +01:00
Miguel Palhas 790d1e2075 feat(maestro): empty panes render an inline spawn draft form (#57) 2026-08-23 19:52:18 +01:00
Miguel Palhas cbfd5eee12 refactor(maestro): extract the spawn form so it can live outside the modal 2026-08-23 19:52:18 +01:00
Miguel Palhas 10cb47c50b feat(maestro): hang draft state off layout leaves, never persisted (#57) 2026-08-23 19:52:18 +01:00
Miguel Palhas dfc4ee91e1 feat(maestro): add shared text-input widget with cursor motion and word ops 2026-08-23 19:50:37 +01:00
Miguel Palhas d5485b8f5d feat(maestro): add home/end/delete and bracketed-paste InputKey variants 2026-08-23 19:44:14 +01:00
naps62-yolo 53660407e3 feat(maestro): add a process-compose dev stack (#52)
ci / web (push) Successful in 28s
ci / rust (push) Successful in 2m34s
2026-08-23 10:02:09 +01:00
naps62 a51809f25a Merge pull request 'test(maestro): cover umbrella binary feature gates and dispatch (NAP-271)' (#5) from nap-271-feature-gate-tests into main
ci / web (push) Successful in 1m15s
ci / rust (push) Successful in 4m13s
Reviewed-on: #5
2026-08-22 19:34:06 +01:00
naps62-yoloandClaude Opus 4.8 135152e930 test(maestro): cover umbrella binary feature gates and dispatch (NAP-271)
ci / web (pull_request) Successful in 31s
ci / rust (pull_request) Successful in 3m24s
The umbrella `maestro` binary had zero coverage of its feature-gate
combinations (cli / daemon / registry / tui). Add:

- Unit tests in `main.rs` for `is_cli_subcommand` classification (CLI vs
  server subcommands, plus the always-false non-`cli` stub) and the
  `-v` verbosity -> tracing-directive mapping.
- A `verbosity_directive` helper, extracted from `cli_filter` purely to
  make the verbosity mapping testable without touching the process-global
  `RUST_LOG` (behavior unchanged).
- An integration test (`tests/cli.rs`) driving the real built binary via
  `CARGO_BIN_EXE_maestro`, asserting the `--help`/`--version`/subcommand
  dispatch surface. Per-subcommand checks are `#[cfg]`-gated so the suite
  is meaningful under every feature subset.
- A CI feature-gate matrix step: build + test each of cli/daemon/registry/
  tui in isolation, and assert the no-feature build is rejected.

All four subsets and the default build pass fmt, clippy, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-22 19:29:20 +01:00
Miguel PalhasandClaude Opus 5 9d777e2a22 fix(maestro): snapshot /proc once per pass and check the whole group
ci / web (pull_request) Successful in 26s
ci / rust (pull_request) Successful in 1m51s
ci / web (push) Successful in 32s
ci / rust (push) Successful in 2m31s
The subtree search returned early whenever it found *any* group member,
so an in-tree stage that was busy or sleeping on a timer masked a
reparented member genuinely parked on the tty.

Checking every member instead would have meant a /proc scan per session
per tick whenever nothing was parked — the common case. ProcGroups
snapshots /proc once per activity pass and indexes by process group, so
the cost is one walk however many sessions the daemon owns, and the
subtree walk disappears: the index is a superset of it.

Also fixes the background-helper test, which had stopped discriminating.
It polled until awaiting_input was true and caught a moment before the
helper spawned, so it passed against a deepest-descendant walk too. It now
waits for the helper to exist first. Both regression tests were re-checked
by patching each old implementation back in.

Reported in review on #51.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:23:17 +01:00
Miguel PalhasandClaude Opus 5 cd7e76d911 fix(maestro): fall back to a /proc scan for the foreground group
ci / web (pull_request) Successful in 26s
ci / rust (pull_request) Successful in 1m32s
A member of the foreground group can sit outside the pane's subtree: a
stage that forks a terminal-reading child and exits leaves that child
reparented to init while it keeps the foreground pgid. The subtree search
finds nothing and the session reads as idle.

An empty subtree result now falls back to scanning /proc for the group.
The scan only runs when the cheap path found nothing, so the common case
still costs a handful of reads.

Tested at the level it can honestly be tested: scan_for_pgid against this
process's own group, and against a group that cannot exist. Reproducing
the reparenting scenario end to end is not practical — setpgid across
sessions is EPERM, and from a shell the window closes as soon as the shell
reclaims the terminal.

Reported in review on #51.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:16:10 +01:00
Miguel PalhasandClaude Opus 5 5c3997d1d3 fix(maestro): search the foreground group for a live member
ci / web (pull_request) Successful in 27s
ci / rust (pull_request) Successful in 1m36s
tpgid names a process *group*, not a process. Its leader can exit while
another member still holds the terminal — a foreground pipeline whose
first stage finished — and then /proc/<tpgid> does not exist, so reading
it directly reported the session idle while it sat waiting on the user.

awaiting_input now searches the pane's subtree for live processes whose
pgrp matches, and asks whether any is parked on the tty. Members stay in
that subtree because their parent shell is alive, so they never reparent.

Reported in review on #51. The regression test needs an interactive shell
— job control is what puts a pipeline in its own process group — and waits
for the leader to actually be gone before asserting, otherwise it passes
against the old lookup while the shell is still foreground.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:10:32 +01:00
Miguel PalhasandClaude Opus 5 dbbe330159 test(maestro): drop catch_unwind from the activity poll helper
ci / rust (pull_request) Successful in 2m23s
ci / web (pull_request) Successful in 29s
A plain deadline loop says the same thing without catching a panic to
turn it back into an assert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:05:02 +01:00
Miguel PalhasandClaude Opus 5 1d611ee402 fix(maestro): ask the kernel which process owns the terminal
ci / web (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 1m44s
awaiting_input walked `children` to the deepest descendant, guessing that
the newest child is the foreground one. An agent that starts a background
helper and then blocks on read landed on the helper, so a session genuinely
waiting on the user was reported as idle.

tpgid in /proc/<pid>/stat is the kernel's own answer — the foreground
process group of the controlling terminal — and the group leader's pid is
the group id. One field read replaces a 64-deep walk.

Adds tests/activity.rs: the first end-to-end coverage of this pipeline,
which had none. The background-helper case fails against the old walk and
passes against tpgid.

Also makes the attach handler subscribe before upgrading. Checking
existence and then subscribing inside the callback left a window for the
liveness watcher to reap in between, which attach::serve compensated for
with a clean close; acquiring the subscription up front means the 404 and
the subscription are one step and the window does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:02:13 +01:00
Miguel PalhasandClaude Opus 5 f31377c34f fix(maestro): three defects the review flagged and left open
ci / web (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 1m58s
ci / web (push) Successful in 34s
ci / rust (push) Successful in 2m44s
SPA attach had no reconnect at all. Any dropped frame closed the socket,
`onClosed` fired, and the pane was destroyed permanently — the TUI
reconnects forever on the same endpoint. Session death also reached the UI
on two uncoordinated channels: the row from the events socket, the pane
from `ws.onclose`, with nothing reconciling them, so either could happen
without the other.

The catalog is now the authority. `shouldRetryClose` decides from session
status: Running means the link dropped, so retry with 500ms-to-10s
backoff; Dead means the PTY stream was sealed and nothing more is coming;
absent means it is gone. Application closes (4000+, a relayed daemon
rejection) and clean closes are never retried. The shell also closes any
pane whose session has left the catalog. The policy lives in reconnect.ts
so it is tested without a DOM.

kill_session checked `has()` before `kill()`, which only opened a window
for the liveness watcher to reap in between — turning a 404 into a 502.
`kill` reports the miss itself.

with_spawn_history logged and carried on when `Arc::get_mut` failed,
producing a daemon whose recent-dirs picker is permanently empty with
nothing to show for it. It panics now: that is a construction-order
mistake, at startup, in code that runs once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:31:54 +01:00
Miguel PalhasandClaude Opus 5 812861f883 fix(maestro): an intervening event breaks a double-click
ci / web (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 1m41s
ci / web (push) Successful in 26s
ci / rust (push) Successful in 2m17s
promote() early-returned for anything that was not a Click, leaving the
pending click intact — so click, drag, click at the same cell within
400ms was reported as a double-click. Same for a scroll or a keystroke
between the two.

Any non-click input now clears the pending click. Two clicks with
something in between are two clicks, however fast.

Reported in review on #48.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:18:40 +01:00
Miguel PalhasandClaude Opus 5 745e9fde8b fix(maestro): make the TUI produce the input it already parses
ci / web (pull_request) Successful in 26s
ci / rust (pull_request) Successful in 2m30s
ci / web (push) Successful in 27s
ci / rust (push) Successful in 2m10s
The keybinding parser validated strings, not whether any producer could
emit the event — so the shipped default `mouse:dblclick(sidebar-row)`
could never fire, and nothing failed.

`MouseKind::DblClick` appeared only in the enum and in parse/format;
`canonicalise` emits Down/Drag/ScrollUp/ScrollDown. Adds a `ClickTracker`
that promotes a second click at the same cell within 400ms, keeping
`canonicalise` a pure function of one event — the tracker takes `now` as a
parameter, so the timing rule is tested without sleeping.

`Region::Sidebar` and `Region::SplitDivider` are deleted: `region_at` can
only return Modal, Tab, SidebarRow, Pane or Unknown, so the parser was
accepting names the renderer cannot produce. It now rejects them.

Shift-Tab is wired to `overlays::prev_field`, which existed with no
caller, leaving the spawn modal cycleable in one direction only. crossterm
reports it as `KeyCode::BackTab` rather than a modified Tab, so `InputKey`
gains a `BackTab` variant that also forwards `\x1b[Z` to the pane.

Left alone: `Action::FocusPane(Direction)` discards its direction, but
that is v1 having one pane per tab, not a defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:59:18 +01:00
Miguel PalhasandClaude Opus 5 30bd9d9042 test(maestro): one mock daemon instead of three answers
ci / web (pull_request) Successful in 29s
ci / rust (pull_request) Successful in 3m17s
ci / web (push) Successful in 30s
ci / rust (push) Successful in 2m39s
There were three answers to "how do I test against a daemon", and the CLI
— the thinnest client — had none, so its formatting and error handling
were exercised by compiling and forking the real binary plus tmux.

Adds crates/maestro-testing with a MockDaemon that serves the real /api/*
surface from maestro-protocol types, backed by a Vec<Session>. It replaces
two ad-hoc stubs: maestro-web's, which returned [{"id":"session-1"}] —
not a valid Session — and maestro-tui's scripted socket with a hardcoded
timeline.

maestro-cli gets its first tests against a daemon: six cases covering the
catalog, that DaemonInfo carries the version and presets its old private
copy dropped, that a structured failure surfaces the daemon's own message,
and that connection-refused stays distinguishable from an HTTP rejection.

maestro-web's proxy suite gains an end-to-end check that an upstream
rejection reaches the browser as a 4502 close carrying the daemon's
message — upstream_close_reason had only unit tests.

e2e keeps the tmux-shaped tests. cli_list_reads_from_daemon still runs the
shipped binary as a subprocess but now against MockDaemon, and
tui_integration.rs is deleted: the real daemon's /api/events contract is
covered by maestro-daemon/tests/local_api.rs and the client reducer by
maestro-tui/tests/catalog_ws.rs. The e2e suite drops from ~32s to ~20s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:51:22 +01:00
Miguel PalhasandClaude Opus 5 6596104789 refactor(maestro): delete the config projections and the dead knobs
ci / web (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 1m49s
ci / web (push) Successful in 39s
ci / rust (push) Successful in 2m48s
maestro-cli/src/config.rs was two pass-throughs, a newtype error, a
two-field projection of MaestroConfigFile, and one four-line or_else with
a single caller. `resolve_daemon_url` moves onto MaestroConfigFile, next
to the schema it reads, and the module goes; its tests move with it.

`[web].dev_proxy_target` is deleted end to end — schema, env var,
normalisation, three tests, README line, and the startup warning that
admitted the server ignores it.

MaestroConfigFile::save had no callers outside its own tests. Deleting it
also removes every skip_serializing_if, the five is_empty impls, and the
Serialize derives that existed only to make its output tidy.

BroadcastTuning was a struct that was never instantiated: all three uses
were `::DEFAULT.field` in const position and there is no [broadcast]
section. Now two plain consts.

$MAESTRO_CONFIG was honoured twice, by resolve_config_path and by clap's
`env =`. Every binary already gets it through clap, so resolve_config_path
and CONFIG_PATH_ENV go and the TUI falls back to default_config_path like
everything else.

Also collapses three layers of 1:1 forwarding: the umbrella's cli_dispatch
shim, and four near-identical 8-line functions in cli/dispatch.rs that
differed only in which run_* they called.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:39:01 +01:00
Miguel PalhasandClaude Opus 5 b20651c33e fix(maestro): close three holes in the wire contract
ci / web (pull_request) Successful in 27s
ci / rust (pull_request) Successful in 1m40s
ci / web (push) Successful in 35s
ci / rust (push) Successful in 2m23s
Each was a typed field that type-checked on both sides and carried
nothing across.

SessionStatus::Dead was never constructed by the daemon, yet the TUI, CLI
and SPA all render it and DESIGN.md specifies the exited badge. Sessions
were removed on death, never marked. They are now marked Dead and kept
until the operator dismisses them, with the PtyBridge sealed rather than
dropped: attached clients still get a Close frame (NAP-226), and the
replay buffer survives so a later attach can read what a crashed agent
printed. Previously that output was destroyed. Dead sessions are
read-only and do not survive a daemon restart.

CreateSessionRequest.env was accepted, stored, echoed back and dropped —
new_session_piped had no env parameter. It now reaches tmux as sorted
`-e KEY=VAL` flags. A key containing `=` or NUL is rejected, since tmux
would parse it as a different assignment than the caller wrote.

PROTOCOL_VERSION was written by the daemon and read by nobody. Adds
`maestro_protocol::is_compatible`, compared on the major component only;
`maestro list` shows the daemon version and the TUI warns on mismatch.

Also stops mapping every non-404 session error to 502: an invalid env key
is a client mistake and now answers 400.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:27:51 +01:00
Miguel PalhasandClaude Opus 5 031c80a40b test(maestro): give the SPA a test runner and a reachable pure core
ci / rust (pull_request) Successful in 2m28s
ci / web (pull_request) Successful in 23s
ci / web (push) Successful in 26s
ci / rust (push) Successful in 1m38s
The browser app had no tests and no way to run any: package.json shipped
dev/build/preview/typecheck, and CI installed Node and pnpm but never ran
anything with them. Its densest logic was module-private, so even with a
runner none of it was reachable.

Extracts four pure modules out of DOM-heavy ones — tree.ts (directory
grouping), keys.ts (terminal escape encoding), paths.ts (completion
arithmetic), html.ts (one escaper, replacing four copies). Extraction
rather than `export` because attach.ts imports xterm's CSS at module
level, which a node test cannot load.

CatalogStore takes an injectable socket factory, scheduler and refetch, so
the reducer and the backoff schedule run without a server. snapshot() now
returns a copy: it returned the live object while events mutated it in
place, so listeners could not diff and every event rebuilt the whole tree.

36 tests across 6 files, and a `web` CI job running typecheck, test and
build. vitest is pinned to 2.x — 4.x requires vite >= 6 and this is vite 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:08:14 +01:00
Miguel PalhasandClaude Opus 5 db44650a17 refactor(maestro): collapse the daemon's double event fan-out
ci / rust (pull_request) Successful in 3m18s
ci / rust (push) Successful in 2m5s
A background task, a second broadcast channel and a whole module existed
to remap SessionEvent onto CatalogEvent one variant at a time.
SessionManager now emits CatalogEvent directly.

Deletes local_events.rs, the publisher task, the LocalEventBus channel,
enum SessionEvent, LocalApiState::events, and the duplicate capacity
constant — events_capacity was declared twice for one logical stream.

Lag semantics improve. There were two lag points: the publisher falling
behind the manager, which broadcast one Lagged to every client, and each
WS subscriber falling behind the bus. Only the per-subscriber one is left,
which pump_events already handled — a slow client now makes only itself
resync.

Adds the first daemon-level tests for WS /api/events: the Snapshot-then-
deltas contract, and that a late subscriber gets a Snapshot of current
state. Both were described only in a comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:37:15 +01:00
Miguel PalhasandClaude Opus 5 f01771568b refactor(maestro): make the TUI dispatch a reducer over effects
ci / rust (pull_request) Successful in 1m57s
ci / rust (push) Successful in 1m54s
dispatch was `async fn -> ()` with reqwest calls and task spawns inlined,
so it could not report intent and the spawn, kill and attach arms had no
test. It now returns Vec<Effect> and App::run performs them.

Two bugs the old shape produced, both fixed and covered:

- The toast overlay could not be dismissed. handle_overlay_input returned
  Pass for Toast(_) and nothing cleared it, so "spawn failed: ..." stayed
  until another overlay replaced it. It now clears and still returns Pass,
  so the keystroke does its normal job instead of being eaten.
- Closing a tab leaked its emulator. Only submit_kill released one, so a
  closed tab's WebSocket task and vt100 parser outlived it forever.
  close_tab now reports the session it dropped — and holds onto it when
  another tab still shows the same one.

AppState::overlay is private behind show_overlay/dismiss_overlay/toast.
Four sites poked the field directly; that is why the toast bug existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:08:08 +01:00
Miguel PalhasandClaude Opus 5 63898cef25 feat(maestro): put a Panes seam behind Tmux
ci / rust (pull_request) Successful in 1m45s
ci / rust (push) Successful in 1m38s
SessionManager held a concrete Tmux, so its decisions — spawn rollback
ordering, liveness diffing, activity change-detection — could only be
reached by forking a real tmux server. Inner now holds an Arc<dyn Panes>;
Tmux is the production adapter, FakePanes the one tests drive.

Arc<dyn Panes> rather than a type parameter: a generic would spread
through LocalApiState, attach and cli, and one vtable hop is free next to
a fork+exec.

Adds tests/session_decisions.rs — six cases, no subprocess, 0.24s.
Replaces the chmod-0o500 rollback test, which detected root and returned
early, so on CI it asserted nothing; the failure is now forced by a
regular file sitting where the sidecar directory's parent must be, which
no uid can work around. It also asserts the rollback *order*, not just
the end state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:44:13 +01:00
Miguel PalhasandClaude Opus 5 fc53d74850 fix(maestro): recognise tmux 3.7's "server exited unexpectedly"
Killing the last session shuts the server down, and tmux >= 3.7 reports
that to the still-connected client with a phrasing `is_no_server_error`
did not match. `list_sessions` then returned Err, and `run_liveness_pass`
logs a warning and returns — so the pass reaped nothing.

Silent by construction: nothing asserted the phrasing set, and the only
symptom was an integration test that looked flaky. Both now covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:37:54 +01:00
Miguel PalhasandClaude Opus 5 ef3d314296 refactor(maestro): finish moving the e2e harness off raw reqwest
ci / rust (pull_request) Successful in 3m11s
ci / rust (push) Successful in 1m27s
Restores the harness's deliberate 5s per-request budget, which the shared
client's 30s default had silently replaced, and converts the last two
hand-rolled daemon calls in e2e.rs (recent-dirs, gitroot). Drops reqwest
from maestro-e2e entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:22:35 +01:00
Miguel PalhasandClaude Opus 5 148f97fa71 chore(maestro): drop the unused tokio dep from the client feature
ci / rust (pull_request) Successful in 5m29s
reqwest brings its own runtime requirement; maestro-protocol never names
tokio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:17:46 +01:00
Miguel PalhasandClaude Opus 5 39cfa50d68 fix(maestro): keep the TUI's two request budgets separate
ci / rust (pull_request) Successful in 5m9s
Collapsing the health probe and the app client into one lost both
policies: the probe's 500ms was a total request timeout, not a connect
timeout, and the app's calls had 5s rather than the client default 30s.
A daemon that accepts TCP and then stalls held the terminal for 30s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:16:30 +01:00
Miguel PalhasandClaude Opus 5 02356f84ab refactor(maestro): route every client through the shared DaemonClient
ci / rust (pull_request) Successful in 3m0s
The CLI, TUI and end-to-end harness each carried their own client: three
reqwest builders with three timeout policies, three copies of the
success-or-body check, and route strings at every call site. Two of them
discarded the daemon's structured error and reported only a status code.

Deletes `maestro-cli/src/http.rs`, both `ws_base` copies in the TUI, the
CLI's partial `DaemonInfo` (it dropped `version` and `presets`), and the
e2e harness's `check_ok`. `maestro-web` gains a protocol dependency and
stops building the error envelope with a format string and reading it back
as untyped JSON; the daemon and the proxy now register routes from the same
constants.

`maestro_web::router` returns `Result` — an unusable daemon URL fails at
startup instead of at first connect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:11:43 +01:00
Miguel PalhasandClaude Opus 5 01a1073943 feat(maestro): own the /api surface in maestro-protocol
The crate held the wire types but not the wire surface, so every consumer
re-derived routes, URL construction and the error envelope.

Adds `routes` (path constants, axum patterns, and a single fallible
`ws_base`), the `ApiError` envelope, `DaemonInfo`, and a `DaemonClient`
behind an optional `client` feature. `SpawnPreset` moves here because it
rides `GET /api/info`; `maestro-config` re-exports it so the on-disk
schema and the payload stay one type.

`ws_base` now rejects a base with no recognised scheme. The five previous
copies disagreed — the CLI errored, the web proxy trusted it, the TUI
passed it through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:11:37 +01:00
Miguel Palhas c5d7a6bf80 Merge remote-tracking branch 'origin/main' into nix
ci / rust (push) Successful in 3m7s
2026-08-22 10:56:21 +01:00
Miguel PalhasandClaude Opus 5 401afbc812 feat(maestro): add a nix flake and Home Manager module
Ships `maestro`, `maestro-web` and the SPA bundle as flake packages, plus a
`services.maestro` Home Manager module that runs the daemon and the web UI as
systemd user services.

`maestro-web`'s build.rs now honours MAESTRO_WEB_DIST and hands the resolved
bundle path to `include_dir!` through MAESTRO_WEB_BUNDLE_DIR. Without it the
Nix build would have to run `pnpm install` mid-compile, which the sandbox has
no network for. Unset, the pnpm path is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:53:40 +01:00
Miguel PalhasandClaude Opus 5 66498415f1 docs(maestro): delete superpowers specs and plans
ci / rust (push) Successful in 2m55s
They described the registry/peer-to-peer design and were kept only for
their superseded banners. CLAUDE.md is the current reference. Repoints
the doc comments that cited them, and fixes the maestro-web README,
which still named MAESTRO_REGISTRY as the Vite proxy override
(MAESTRO_DAEMON_URL since the refactor).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #39

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:08:03 +00:00
151 changed files with 7929 additions and 19592 deletions
-10
View File
@@ -1,10 +0,0 @@
target/
worktrees/
.git/
.github/
.claude/
docs/
web/node_modules/
web/dist/
*.md
.env*
-1
View File
@@ -1 +0,0 @@
use flake
+45
View File
@@ -52,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
+9 -2
View File
@@ -6,5 +6,12 @@ Cargo.lock.bak
# Local git worktrees for in-flight branches (never tracked)
worktrees/
# direnv / nix dev shell
/.direnv
# nix build output symlinks
/result
/result-*
# direnv (`use flake`)
/.direnv/
# `nix run .#dev` scratch state (tmux socket)
/.dev/
+45 -21
View File
@@ -1,54 +1,78 @@
# Maestro
A unified terminal-session orchestrator. One place — laptop, desktop, or phone — to see and attach to every interactive shell or agent (Claude Code, opencode, plain bash) running across every machine you care about. tmux for persistence, WebSocket for transport, daemon-per-host federated by a small always-on registry.
A terminal-session orchestrator for one machine. One daemon per host owns every
interactive shell or agent (Claude Code, opencode, plain bash) running on it;
clients — CLI, TUI, browser — attach over a plain HTTP + WebSocket API. tmux for
persistence, WebSocket for transport.
## Where the spec lives
## Architecture
`docs/superpowers/specs/2026-05-24-maestro-design.md` is the source of truth for architecture, wire protocol, session model, auth, repo layout, and scope boundary. **Read it before writing any code in this repo.**
```
maestro daemon tmux wrapper + session API on 127.0.0.1:7070
├── GET/POST /api/sessions list / spawn
├── DELETE /api/sessions/:id kill
├── GET /api/info hostname, version, home dir, spawn presets
├── GET /api/dirs directory listing (spawn autocomplete)
├── GET /api/recent-dirs spawn history
├── GET /api/gitroot enclosing repo of a path
├── WS /api/events Snapshot + session deltas
└── WS /api/attach/:id raw PTY bytes
maestro list/spawn/attach/kill CLI client
maestro tui ratatui client
maestro-web serves the SPA, reverse-proxies /api/* to the daemon
```
**No authentication layer, no registry, no peer-to-peer.** Whoever can reach the
daemon's bind address can drive every session on the host — same trust model as
tmux itself. The operator picks the perimeter: `127.0.0.1` for local-only, a
WireGuard/LAN address to share with their own devices, never a public IP.
Every config field has a default, so `~/.config/maestro/config.toml` is
optional.
## Where the work is
Linear project **Maestro** under team **Naps62**. Issues `NAP-211..218` cover the prototype; each has `blockedBy` set so the dependency chain is explicit. **`NAP-211` is the only unblocked starting point.**
Linear project **Maestro** under team **Naps62**.
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from "available now" (status backlog/pending, not blocked).
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from
"available now" (status backlog/pending, not blocked).
## Repo layout
Cargo workspace. NAP-245 unified the per-binary crates behind a single
`crates/maestro/` umbrella that ships the `maestro` binary with feature gates
(`cli` / `daemon` / `registry`, default = all three). The library crates kept
their existing shape; only the per-binary `main.rs` files were retired.
(`cli` / `daemon` / `tui`, default = all three).
```
crates/
├── maestro/ # umbrella binary `maestro` (cli + daemon + registry, feature-gated) (NAP-245)
├── maestro/ # umbrella binary `maestro` (cli + daemon + tui, feature-gated) (NAP-245)
├── maestro-protocol/ # shared wire types (NAP-211)
├── maestro-config/ # shared on-disk config schema (NAP-233)
├── maestro-names/ # orchestra-themed session-handle generator (#23)
├── maestro-cli/ # `maestro list/spawn/attach/kill/policy` dispatch (NAP-216)
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + WS endpoints (NAP-212, NAP-214)
├── maestro-registry/ # `maestro registry` — phone book + HTTP API (NAP-213, NAP-215)
├── maestro-cli/ # `maestro list/spawn/attach/kill` dispatch (NAP-216)
├── maestro-daemon/ # `maestro daemon` — tmux wrapper + HTTP/WS API (NAP-212, NAP-214)
├── maestro-web/ # standalone `maestro-web` browser UI (NAP-241, NAP-249)
├── maestro-tui/ # `maestro tui` — ratatui session manager (NAP-275 epic)
├── maestro-installer/ # `maestro-install` wizard: cargo install + systemd (NAP-225)
└── maestro-e2e/ # cross-process integration tests (NAP-217)
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.
`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
+10 -155
View File
@@ -317,35 +317,6 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -437,15 +408,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -488,15 +450,6 @@ dependencies = [
"syn",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "either"
version = "1.16.0"
@@ -1015,19 +968,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64",
"js-sys",
"ring",
"serde",
"serde_json",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1073,12 +1013,6 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1116,7 +1050,6 @@ dependencies = [
"clap",
"maestro-cli",
"maestro-daemon",
"maestro-registry",
"maestro-tui",
"tokio",
"tracing-subscriber",
@@ -1133,9 +1066,8 @@ dependencies = [
"libc",
"maestro-config",
"maestro-protocol",
"maestro-testing",
"nix",
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
@@ -1144,7 +1076,6 @@ dependencies = [
"toml",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
]
@@ -1153,6 +1084,7 @@ name = "maestro-config"
version = "0.1.0"
dependencies = [
"dirs",
"maestro-protocol",
"serde",
"tempfile",
"thiserror 1.0.69",
@@ -1168,11 +1100,9 @@ dependencies = [
"clap",
"dirs",
"futures-util",
"jsonwebtoken",
"libc",
"maestro-config",
"maestro-protocol",
"maestro-registry",
"reqwest",
"serde",
"serde_json",
@@ -1180,7 +1110,6 @@ dependencies = [
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite 0.21.0",
"toml",
"tracing",
"tracing-subscriber",
"urlencoding",
@@ -1194,8 +1123,8 @@ dependencies = [
"chrono",
"futures-util",
"maestro-protocol",
"maestro-testing",
"maestro-tui",
"reqwest",
"serde",
"serde_json",
"tempfile",
@@ -1217,45 +1146,29 @@ 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",
"serde",
"serde_json",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite 0.21.0",
"toml",
"tracing",
"tracing-subscriber",
"uuid",
]
@@ -1270,8 +1183,8 @@ dependencies = [
"futures-util",
"maestro-config",
"maestro-protocol",
"maestro-testing",
"ratatui",
"reqwest",
"serde_json",
"thiserror 1.0.69",
"tokio",
@@ -1292,6 +1205,8 @@ dependencies = [
"futures-util",
"include_dir",
"maestro-config",
"maestro-protocol",
"maestro-testing",
"mime_guess",
"reqwest",
"serde_json",
@@ -1301,6 +1216,7 @@ dependencies = [
"tokio-tungstenite 0.21.0",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@@ -1373,12 +1289,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1456,12 +1366,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1490,22 +1394,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1707,8 +1595,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"cookie",
"cookie_store",
"futures-core",
"futures-util",
"http",
@@ -2165,37 +2051,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"
+2 -4
View File
@@ -16,16 +16,14 @@ 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"
+9 -10
View File
@@ -54,7 +54,7 @@ Tints/glow derive from the accent at low alpha: `--accent-tint` (0.12),
## Typography
**Monospace is the UI typeface** (brand identity), system-stack only (offline-
safe, zero web-font dependency, embedded clean into the registry binary):
safe, zero web-font dependency, embedded clean into the web binary):
- `--font-mono`: `ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", "Roboto Mono", Menlo, Consolas, monospace` — chrome, labels, data, ids, the wordmark.
- `--font-sans`: `system-ui` stack — reserved for the few prose strings (empty-state sentences).
@@ -86,9 +86,8 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
## Layout & structure
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
breakpoint stacks the header (full-width search, wrapped actions) and drops the
daemon chips to their own wrapped line. `≥600px` switches the spawn modal from
bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
breakpoint stacks the header (full-width search, wrapped actions). `≥600px`
switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as
terminal atmosphere (very low contrast, not decoration).
## Components
@@ -96,10 +95,10 @@ terminal atmosphere (very low contrast, not decoration).
Three surfaces, all DOM built in TS, styled by class:
- **Catalog** — sticky blurred header (pulsing wordmark, search, theme toggle,
Spawn); status bar (`N sessions · M live` tally + daemon chips); session tree
(collapsible folders with counts, leaf rows with status dot, name, `@daemon`
+ command meta, exited badge, reveal-on-hover kill). Empty states are
distinct and teach: waiting-for-daemon, no-sessions, no-search-match.
Spawn); status bar (`N sessions · M live` tally); session tree (collapsible
directory folders with counts, leaf rows with status dot, name, command meta,
exited badge, reveal-on-hover kill). Empty states are distinct and teach:
no-sessions, no-search-match.
- **Attach** — header (back, `session <id>`, live/closed/error state pill) over
a framed dark xterm with a green cursor.
- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus
@@ -112,5 +111,5 @@ status — never decoration.
## Dev review aids (DEV-only, stripped from production)
`web/src/mock.ts` + query params seed a representative catalog without a running
registry: `?mock` (seed sessions/daemons), `?mock&spawn` (open the spawn
modal), `?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal),
`?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`.
-37
View File
@@ -1,37 +0,0 @@
# syntax=docker/dockerfile:1.7
#
# Image for the Maestro registry.
#
# NAP-245 retired the per-binary crates: the registry now ships as the
# `registry` subcommand of the unified `maestro` binary. This image builds that
# binary with only the `registry` feature enabled (no cli/daemon/tui), so it
# stays small and pulls only maestro-registry + protocol + config.
#
# Build: docker build -t maestro-registry .
# Run: docker run --rm -p 8080:8080 -e MAESTRO_TOKEN=<secret> maestro-registry
#
# On Dokploy: point at this Dockerfile, expose port 8080 (or whatever you
# set MAESTRO_REGISTRY_BIND_ADDR to), and set MAESTRO_TOKEN as a secret env.
# No persistent volume is required when MAESTRO_TOKEN is provided.
FROM rust:1-bookworm AS builder
WORKDIR /build
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release -p maestro --no-default-features --features registry \
&& cp /build/target/release/maestro /maestro-registry
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /maestro-registry /usr/local/bin/maestro-registry
# Default to all-interfaces so Dokploy / Traefik can reach the service.
# Override via env on the platform.
ENV MAESTRO_REGISTRY_BIND_ADDR=0.0.0.0:8080
EXPOSE 8080
# The on-disk binary is the unified `maestro`; run its `registry` subcommand.
ENTRYPOINT ["/usr/local/bin/maestro-registry", "registry"]
+4 -36
View File
@@ -1,41 +1,9 @@
# Common dev / build entry points. Run `just` with no args to list.
# The registry (:8080) is the phone book, the daemon (:7070) runs the tmux
# sessions, and Vite serves the UI at http://localhost:5173 (attach is proxied
# to the daemon; see web/vite.config.ts). Override the secret with MAESTRO_TOKEN.
# Run the full local stack — registry + daemon + web UI; Ctrl-C stops all three.
# 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:
#!/usr/bin/env bash
set -euo pipefail
token="${MAESTRO_TOKEN:-devtoken}"
bin="target/debug/maestro"
echo "▶ building maestro…"
cargo build --bin maestro
pids=()
cleanup() { echo; echo "▶ stopping stack…"; for p in "${pids[@]:-}"; do kill "$p" 2>/dev/null || true; done; }
trap cleanup EXIT INT TERM
echo "▶ registry → http://127.0.0.1:8080"
MAESTRO_TOKEN="$token" MAESTRO_REGISTRY_BIND_ADDR=127.0.0.1:8080 "$bin" registry &
pids+=($!)
# Wait for the registry to accept connections before starting the daemon.
for _ in $(seq 1 50); do (exec 3<>/dev/tcp/127.0.0.1/8080) 2>/dev/null && break || sleep 0.2; done
echo "▶ daemon → ws://127.0.0.1:7070 (id: $(hostname))"
MAESTRO_TOKEN="$token" \
MAESTRO_REGISTRY_URL=http://127.0.0.1:8080 \
MAESTRO_DAEMON_BIND_ADDR=127.0.0.1:7070 \
MAESTRO_DAEMON_URL=ws://127.0.0.1:7070 \
"$bin" daemon run &
pids+=($!)
for _ in $(seq 1 50); do (exec 3<>/dev/tcp/127.0.0.1/7070) 2>/dev/null && break || sleep 0.2; done
echo "▶ web UI → http://localhost:5173 (Ctrl-C to stop everything)"
pnpm -C web install
MAESTRO_REGISTRY=http://127.0.0.1:8080 MAESTRO_DAEMON=http://127.0.0.1:7070 \
pnpm -C web dev
nix run .#dev
# Build the maestro-web binary together with the SPA bundle it embeds.
web:
@@ -45,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
+12 -13
View File
@@ -7,22 +7,21 @@ product
## Users
A single technical operator (a developer) managing every interactive shell and
agent — Claude Code, opencode, plain bash — running across multiple machines.
The job-to-be-done: from any device (laptop, desktop, **phone**), get an
accurate at-a-glance picture of all live sessions across all hosts, then attach
to one and drive its terminal. Usage is often glance-and-go: checking on a
agent — Claude Code, opencode, plain bash — running on a machine. The
job-to-be-done: from any device (laptop, desktop, **phone**), get an accurate
at-a-glance picture of all live sessions on that host, then attach to one and
drive its terminal. Usage is often glance-and-go: checking on a
long-running agent, killing a stray session, or hopping into a shell mid-task.
Mobile-first is a hard requirement — touch targets, viewport-fit safe areas,
and bottom-sheet modals already reflect this.
## Product Purpose
Maestro is a unified terminal-session orchestrator: one place to see and attach
to every interactive session running across every machine the operator cares
about. tmux for persistence, WebSocket for transport, a daemon-per-host
federated by a small always-on registry. The web UI is the browser face of
that system — it renders the cross-host session catalog and provides the attach
terminal (xterm.js). Success = the operator trusts the picture is accurate and
Maestro is a terminal-session orchestrator: one place to see and attach to
every interactive session running on a machine. tmux for persistence,
WebSocket for transport, one daemon per host. The web UI is the browser face of
that system — it renders the session catalog and provides the attach terminal
(xterm.js). Success = the operator trusts the picture is accurate and
current, and can get from "open the page" to "typing in the right session" in
seconds, on any device.
@@ -51,14 +50,14 @@ efficient, calm / uncluttered, with earned moments of delight.
## Design Principles
1. **The scan is the product.** Optimize for instant comprehension of "what's
running, where, and is it healthy" across many sessions and hosts. Status
legibility beats everything else.
running, where, and is it healthy" across many sessions. Status legibility
beats everything else.
2. **Calm under load.** Stay uncluttered whether there are 3 sessions or 300;
hierarchy and grouping carry the weight, not color-shouting every row.
3. **Fast and keyboard-literate.** Low-friction, efficient, gets out of the
way; honor keyboard navigation, don't make the operator hunt.
4. **Monospace as identity.** Treat the mono/sans pairing as deliberate brand
typography, not just terminal output. Ids, hosts, counts, and state read as
typography, not just terminal output. Ids, paths, counts, and state read as
precise technical data.
5. **Earned delight.** Personality lives in motion and micro-detail (live-status
pulse, attach transitions, cursor cues), never in noise — always craft.
+4 -4
View File
@@ -17,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"] }
@@ -28,8 +26,10 @@ 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]
maestro-testing.workspace = true
tempfile.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
uuid.workspace = true
+8 -132
View File
@@ -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,8 +23,8 @@ 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")]
@@ -34,11 +33,8 @@ pub enum AttachError {
impl AttachError {
/// True when the failure was an inability to reach the WS endpoint at the
/// transport level (connection refused). Used to fall back from the home
/// daemon broker to the legacy registry direct-attach path (NAP-244).
/// An HTTP-level rejection from a *reachable* daemon (e.g. the broker
/// returns 404 / 502) is deliberately NOT treated as unreachable — the
/// daemon answered, so we surface its error instead of masking it.
/// transport level (connection refused), as opposed to an HTTP-level
/// rejection from a daemon that did answer.
pub fn is_unreachable(&self) -> bool {
use std::io::ErrorKind;
use tokio_tungstenite::tungstenite::Error as WsError;
@@ -66,47 +62,6 @@ pub fn parse_detach_key(spec: &str) -> Result<u8, AttachError> {
}
}
/// Build the attach WS URL from a `ConnectInfo` and session ID.
#[allow(clippy::result_large_err)]
pub fn attach_url(info: &ConnectInfo, session_id: Uuid) -> Result<String, AttachError> {
let base = info.daemon_url.trim_end_matches('/');
if !(base.starts_with("ws://") || base.starts_with("wss://")) {
return Err(AttachError::InvalidDaemonUrl(info.daemon_url.clone()));
}
Ok(format!(
"{base}/sessions/{session_id}/stream?token={token}",
token = urlencoding::encode(&info.jwt)
))
}
/// Build the home-daemon broker attach WS URL from the daemon's HTTP base.
///
/// The broker endpoint (`WS /api/attach/:id`, NAP-243) lives on the daemon's
/// `bind_addr`, so we take the HTTP base (`http://127.0.0.1:7070`), swap the
/// scheme to its WS equivalent, and append the route. The daemon picks the
/// transport and dials the target (which verifies the registry-minted
/// pair-attach JWT — session binding, not a per-peer allow-list); the CLI just
/// pumps frames.
#[allow(clippy::result_large_err)]
pub fn broker_attach_url(
home_daemon_http_base: &str,
session_id: Uuid,
) -> Result<String, AttachError> {
let base = home_daemon_http_base.trim_end_matches('/');
let ws_base = if let Some(rest) = base.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = base.strip_prefix("http://") {
format!("ws://{rest}")
} else if base.starts_with("ws://") || base.starts_with("wss://") {
base.to_string()
} else {
return Err(AttachError::InvalidDaemonUrl(
home_daemon_http_base.to_string(),
));
};
Ok(format!("{ws_base}/api/attach/{session_id}"))
}
/// RAII guard that puts the terminal into raw mode and restores on drop.
#[derive(Debug)]
struct RawTty {
@@ -151,24 +106,9 @@ fn current_winsize() -> Option<(u16, u16)> {
Some((ws.ws_col, ws.ws_row))
}
/// Drive the attach loop until the client detaches or the remote closes.
///
/// `connect_info` is whatever `GET /api/sessions/:id/connect-info` returned;
/// `detach_byte` is the single byte that exits cleanly without killing the
/// session.
#[allow(clippy::result_large_err)]
pub async fn run_attach(
connect_info: ConnectInfo,
session_id: Uuid,
detach_byte: u8,
) -> Result<(), AttachError> {
let url = attach_url(&connect_info, session_id)?;
run_attach_url(&url, detach_byte).await
}
/// Drive the attach loop against an already-built WS `url`. Shared by the
/// home-daemon broker path ([`broker_attach_url`]) and the legacy registry
/// direct-attach path ([`run_attach`]) — both speak the same PTY wire format.
/// Drive the attach loop against an already-built WS `url` ([`attach_url`])
/// until the client detaches or the remote closes. `detach_byte` is the single
/// byte that exits cleanly without killing the session.
#[allow(clippy::result_large_err)]
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
@@ -273,8 +213,6 @@ pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachErro
#[cfg(test)]
mod tests {
use super::*;
use maestro_protocol::ConnectInfo;
use uuid::Uuid;
#[test]
fn parse_detach_key_handles_caret_form() {
@@ -295,66 +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 broker_attach_url_swaps_scheme_and_appends_route() {
let id = Uuid::from_u128(0xabcd);
assert_eq!(
broker_attach_url("http://127.0.0.1:7070", id).unwrap(),
format!("ws://127.0.0.1:7070/api/attach/{id}")
);
assert_eq!(
broker_attach_url("https://laptop.lan:7070/", id).unwrap(),
format!("wss://laptop.lan:7070/api/attach/{id}")
);
// already-ws bases pass through
assert_eq!(
broker_attach_url("ws://10.0.0.1:7070", id).unwrap(),
format!("ws://10.0.0.1:7070/api/attach/{id}")
);
}
#[test]
fn broker_attach_url_rejects_unknown_scheme() {
assert!(matches!(
broker_attach_url("ftp://x", Uuid::nil()),
Err(AttachError::InvalidDaemonUrl(_))
));
}
#[test]
fn attach_url_trims_trailing_slash() {
let info = ConnectInfo {
daemon_url: "wss://reg.example.com/".into(),
jwt: "t".into(),
};
let id = Uuid::nil();
let url = attach_url(&info, id).unwrap();
assert!(!url.contains("//sessions"), "{url}");
}
}
+9 -30
View File
@@ -9,14 +9,15 @@ use uuid::Uuid;
#[command(
name = "maestro",
version,
about = "Control terminal sessions across one or more maestrod hosts",
about = "Control terminal sessions on the local maestro daemon",
arg_required_else_help = true
)]
pub struct Cli {
/// Override the registry URL for this invocation (e.g. `https://maestro.example.com`).
/// If omitted, `[shared].registry_url` from the unified config file is used.
#[arg(long, global = true, env = "MAESTRO_REGISTRY_URL")]
pub registry_url: Option<String>,
/// Override the daemon HTTP base for this invocation (e.g.
/// `http://127.0.0.1:7070`). If omitted, `[cli].daemon_url` from the
/// unified config file is used.
#[arg(long, global = true, env = "MAESTRO_DAEMON_URL")]
pub daemon_url: Option<String>,
/// Path to the unified Maestro config file. Default `~/.config/maestro/config.toml`.
#[arg(long, global = true, env = "MAESTRO_CONFIG")]
@@ -32,23 +33,18 @@ pub struct Cli {
#[derive(Debug, Subcommand)]
pub enum Command {
/// List all sessions, grouped by daemon.
/// List all sessions.
List(ListArgs),
/// Spawn a new session on a given daemon.
/// Spawn a new session.
Spawn(SpawnArgs),
/// Attach to a session over WebSocket. Bytes flow through the local TTY.
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// Set the default registry URL persisted in the CLI config.
SetRegistry(SetRegistryArgs),
}
#[derive(Debug, clap::Args)]
pub struct ListArgs {
/// Show only sessions on this daemon.
#[arg(long)]
pub daemon: Option<String>,
/// Output as JSON instead of a table.
#[arg(long)]
pub json: bool,
@@ -56,9 +52,6 @@ pub struct ListArgs {
#[derive(Debug, Clone, clap::Args)]
pub struct SpawnArgs {
/// Daemon ID to spawn on.
#[arg(long)]
pub daemon: String,
/// Working directory for the session (must exist on the daemon's host).
#[arg(long)]
pub cwd: PathBuf,
@@ -97,12 +90,6 @@ pub struct KillArgs {
pub session_id: Uuid,
}
#[derive(Debug, clap::Args)]
pub struct SetRegistryArgs {
/// Registry base URL to persist.
pub url: String,
}
fn parse_kv(s: &str) -> Result<(String, String), String> {
match s.split_once('=') {
Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())),
@@ -117,10 +104,9 @@ mod tests {
#[test]
fn parses_list() {
let cli = Cli::try_parse_from(["maestro", "list", "--daemon", "laptop", "--json"]).unwrap();
let cli = Cli::try_parse_from(["maestro", "list", "--json"]).unwrap();
match cli.command {
Command::List(args) => {
assert_eq!(args.daemon.as_deref(), Some("laptop"));
assert!(args.json);
}
_ => panic!("wrong subcommand"),
@@ -132,8 +118,6 @@ mod tests {
let cli = Cli::try_parse_from([
"maestro",
"spawn",
"--daemon",
"vm1",
"--cwd",
"/tmp",
"--command",
@@ -151,7 +135,6 @@ mod tests {
.unwrap();
match cli.command {
Command::Spawn(a) => {
assert_eq!(a.daemon, "vm1");
assert_eq!(a.command, "bash");
assert_eq!(a.branch.as_deref(), Some("feat/x"));
assert!(a.cleanup_on_kill);
@@ -169,8 +152,6 @@ mod tests {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--daemon",
"vm1",
"--cwd",
"/tmp",
"--command",
@@ -197,8 +178,6 @@ mod tests {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--daemon",
"x",
"--cwd",
"/",
"--command",
-250
View File
@@ -1,250 +0,0 @@
//! CLI view over the unified Maestro config at `~/.config/maestro/config.toml`.
//!
//! Reads `[shared].registry_url` (registry HTTP base) and `[cli].cookie`
//! (persisted `maestro_session` JWT). All other sections in the file are
//! preserved on write.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use maestro_config::MaestroConfigFile;
pub use maestro_config::CONFIG_PATH_ENV;
/// Last-resort home-daemon HTTP base when neither config nor a local
/// `[daemon].bind_addr` tells us otherwise. Matches the daemon's default
/// `bind_addr` (`crates/maestro-daemon` defaults).
pub const DEFAULT_HOME_DAEMON_URL: &str = "http://127.0.0.1:7070";
/// `~/.config/maestro/config.toml` (XDG-aware).
pub fn default_config_path() -> Result<PathBuf, ConfigError> {
Ok(maestro_config::default_config_path()?)
}
/// `$MAESTRO_CONFIG` if set, else [`default_config_path`].
pub fn resolve_config_path() -> Result<PathBuf, ConfigError> {
Ok(maestro_config::resolve_config_path()?)
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error(transparent)]
Inner(#[from] maestro_config::ConfigError),
#[error(
"no registry URL configured — pass --registry-url, set \
MAESTRO_REGISTRY_URL, or run `maestro set-registry <url>`"
)]
NoRegistryUrl,
}
/// Resolved CLI config — the subset of the unified file that this binary
/// actually consumes.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CliConfig {
pub registry_url: Option<String>,
pub cookie: Option<String>,
/// `[cli].home_daemon_url` — explicit home-daemon HTTP base (NAP-244).
pub home_daemon_url: Option<String>,
/// `[daemon].bind_addr` if a daemon is configured on this host. Used to
/// derive the default home-daemon URL when `home_daemon_url` is unset.
pub daemon_bind_addr: Option<SocketAddr>,
}
impl CliConfig {
/// Load the subset of the unified file the CLI consumes:
/// `[shared].registry_url`, `[cli].cookie`, `[cli].home_daemon_url`, and
/// `[daemon].bind_addr` (for default home-daemon derivation). Returns an
/// empty config if the file is missing — the CLI tolerates a missing file
/// so `maestro set-registry <url>` can bootstrap it.
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let file = MaestroConfigFile::load_or_default(path)?;
Ok(Self {
registry_url: file.shared.registry_url,
cookie: file.cli.cookie,
home_daemon_url: file.cli.home_daemon_url,
daemon_bind_addr: file.daemon.bind_addr,
})
}
/// Resolve the home-daemon HTTP base the broker path should target.
///
/// Precedence: explicit `override_url` (CLI flag / env) → `[cli]
/// .home_daemon_url` → `http://<[daemon].bind_addr>` → the built-in
/// [`DEFAULT_HOME_DAEMON_URL`]. Always returns a value; reachability is
/// decided later by whether the connection actually succeeds.
pub fn resolve_home_daemon_url(&self, override_url: Option<String>) -> String {
override_url
.or_else(|| self.home_daemon_url.clone())
.or_else(|| self.daemon_bind_addr.map(|a| format!("http://{a}")))
.unwrap_or_else(|| DEFAULT_HOME_DAEMON_URL.to_string())
}
/// Persist `[shared].registry_url`. Read-modify-write — preserves
/// any other sections already in the file (`[daemon]`, `[registry]`,
/// existing `[cli].cookie`).
pub fn save_registry_url(path: &Path, url: &str) -> Result<(), ConfigError> {
let mut file = MaestroConfigFile::load_or_default(path)?;
file.shared.registry_url = Some(url.to_string());
file.save(path)?;
Ok(())
}
/// Persist `[cli].cookie`. Same read-modify-write semantics as
/// [`save_registry_url`]; in particular this does not touch
/// `[shared].registry_url`.
pub fn save_cookie(path: &Path, cookie: &str) -> Result<(), ConfigError> {
let mut file = MaestroConfigFile::load_or_default(path)?;
file.cli.cookie = Some(cookie.to_string());
file.save(path)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use maestro_config::SharedSection;
fn write_file(path: &Path, body: &str) {
std::fs::write(path, body).unwrap();
}
#[test]
fn missing_file_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let cfg = CliConfig::load(&dir.path().join("config.toml")).unwrap();
assert_eq!(cfg, CliConfig::default());
}
#[test]
fn load_reads_shared_registry_url_and_cli_cookie() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_file(
&path,
r#"
[shared]
registry_url = "https://r.example"
[cli]
cookie = "eyJ..."
"#,
);
let cfg = CliConfig::load(&path).unwrap();
assert_eq!(cfg.registry_url.as_deref(), Some("https://r.example"));
assert_eq!(cfg.cookie.as_deref(), Some("eyJ..."));
}
#[test]
fn save_registry_url_preserves_other_sections() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_file(
&path,
r#"
[shared]
token = "abc"
[daemon]
daemon_url = "ws://laptop:7070"
"#,
);
CliConfig::save_registry_url(&path, "https://new.example/").unwrap();
let after = MaestroConfigFile::load(&path).unwrap();
assert_eq!(
after.shared.registry_url.as_deref(),
Some("https://new.example/")
);
assert_eq!(after.shared.token.as_deref(), Some("abc"));
assert_eq!(after.daemon.daemon_url.as_deref(), Some("ws://laptop:7070"));
}
#[test]
fn save_cookie_does_not_touch_registry_url() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_file(
&path,
r#"
[shared]
registry_url = "https://r.example"
"#,
);
CliConfig::save_cookie(&path, "eyJ.new").unwrap();
let after = MaestroConfigFile::load(&path).unwrap();
assert_eq!(
after.shared.registry_url.as_deref(),
Some("https://r.example")
);
assert_eq!(after.cli.cookie.as_deref(), Some("eyJ.new"));
}
#[test]
fn resolve_home_daemon_url_precedence() {
// override wins over everything
let cfg = CliConfig {
home_daemon_url: Some("http://from-cli-section:7070".into()),
daemon_bind_addr: Some("127.0.0.1:9999".parse().unwrap()),
..Default::default()
};
assert_eq!(
cfg.resolve_home_daemon_url(Some("http://override:1".into())),
"http://override:1"
);
// [cli].home_daemon_url next
assert_eq!(
cfg.resolve_home_daemon_url(None),
"http://from-cli-section:7070"
);
// derive from [daemon].bind_addr when no explicit url
let cfg = CliConfig {
daemon_bind_addr: Some("10.0.0.7:7070".parse().unwrap()),
..Default::default()
};
assert_eq!(cfg.resolve_home_daemon_url(None), "http://10.0.0.7:7070");
// built-in default when nothing is configured
let cfg = CliConfig::default();
assert_eq!(cfg.resolve_home_daemon_url(None), DEFAULT_HOME_DAEMON_URL);
}
#[test]
fn load_reads_home_daemon_url_and_daemon_bind_addr() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_file(
&path,
r#"
[cli]
home_daemon_url = "http://laptop:7070"
[daemon]
bind_addr = "127.0.0.1:7071"
"#,
);
let cfg = CliConfig::load(&path).unwrap();
assert_eq!(cfg.home_daemon_url.as_deref(), Some("http://laptop:7070"));
assert_eq!(
cfg.daemon_bind_addr,
Some("127.0.0.1:7071".parse().unwrap())
);
}
#[test]
fn save_creates_file_if_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested/config.toml");
CliConfig::save_registry_url(&path, "https://x").unwrap();
assert!(path.exists());
let after = MaestroConfigFile::load(&path).unwrap();
assert_eq!(
after,
MaestroConfigFile {
shared: SharedSection {
registry_url: Some("https://x".into()),
token: None,
},
..Default::default()
}
);
}
}
+37 -236
View File
@@ -1,41 +1,25 @@
//! Public dispatch for the `maestro` CLI subcommands.
//!
//! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
//! binary can dispatch to the same logic.
//!
//! ## Transport (NAP-244)
//!
//! The CLI talks to its **home daemon** (`[cli].home_daemon_url`, default
//! `http://127.0.0.1:7070`) and lets the daemon broker the rest:
//!
//! * `list` reads the daemon's local catalog mirror (own ∪ peer sessions).
//! * `spawn` / `kill` hit the daemon's local session endpoints.
//! * `attach` opens the daemon's `WS /api/attach/:id` broker, which picks the
//! transport and dials the target (the target verifies the registry-minted
//! pair-attach JWT — session binding, not a per-peer allow-list).
//!
//! If the home daemon is **unreachable** (connection refused / not running),
//! every command falls back to the legacy registry path (registry HTTP +
//! direct WS) with a one-line stderr warning, so migration is painless. As of
//! NAP-327 the daemon brokers cross-daemon create *and* kill over the registry
//! control link, so those no longer need a fallback — only an unreachable home
//! daemon does.
//! Every command talks to the local daemon (`[cli].daemon_url`, default
//! `http://127.0.0.1:7070`): `list` reads its session catalog, `spawn` / `kill`
//! hit its session endpoints, and `attach` opens `WS /api/attach/:id`.
use std::path::{Path, PathBuf};
use crate::attach::{broker_attach_url, parse_detach_key, run_attach, run_attach_url};
use crate::cli::{AttachArgs, Command, KillArgs, ListArgs, SetRegistryArgs, SpawnArgs};
use crate::config::{default_config_path, CliConfig, ConfigError};
use crate::http::{HttpError, RegistryClient};
use crate::sessions::{fetch_connect_info, run_kill, run_list, run_spawn};
use maestro_protocol::DaemonClient;
use crate::attach::{parse_detach_key, run_attach_url};
use crate::cli::Command;
use crate::sessions::{run_kill, run_list, run_spawn};
use maestro_config::{default_config_path, MaestroConfigFile};
#[derive(Debug, thiserror::Error)]
#[allow(clippy::result_large_err)]
pub enum CliError {
#[error("config: {0}")]
Config(#[from] ConfigError),
Config(#[from] maestro_config::ConfigError),
#[error("{0}")]
Http(#[from] crate::http::HttpError),
Client(#[from] maestro_protocol::ClientError),
#[error("{0}")]
Attach(#[from] crate::attach::AttachError),
#[error("{0}")]
@@ -43,237 +27,54 @@ pub enum CliError {
}
/// Dispatch a parsed top-level subcommand. The umbrella binary passes the
/// global flags (`--config`, `--registry-url`, `--home-daemon-url`) in
/// alongside the variant.
/// global flags (`--config`, `--daemon-url`) in alongside the variant.
#[allow(clippy::result_large_err)]
pub async fn dispatch(
command: Command,
config: Option<PathBuf>,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
daemon_url_override: Option<String>,
) -> Result<(), CliError> {
let config_path = match config {
Some(p) => p,
None => default_config_path()?,
};
let daemon = client(&config_path, daemon_url_override)?;
match command {
Command::SetRegistry(args) => set_registry(&config_path, args),
Command::List(args) => {
list(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
let mut stdout = std::io::stdout().lock();
run_list(&daemon, &args, &mut stdout).await?;
}
Command::Spawn(args) => {
spawn(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
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) => {
attach(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
let detach = parse_detach_key(&args.detach_key)?;
run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?;
}
Command::Kill(args) => {
kill(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id);
}
}
}
#[allow(clippy::result_large_err)]
fn set_registry(config_path: &Path, args: SetRegistryArgs) -> Result<(), CliError> {
let url = args.url.trim_end_matches('/');
CliConfig::save_registry_url(config_path, url)?;
println!("registry URL saved to {}", config_path.display());
Ok(())
}
#[allow(clippy::result_large_err)]
async fn list(
config_path: &Path,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
args: ListArgs,
) -> Result<(), CliError> {
let cfg = CliConfig::load(config_path)?;
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override);
let daemon = daemon_client(&home_url, config_path);
let mut stdout = std::io::stdout().lock();
match run_list(&daemon, &args, &mut stdout).await {
Ok(()) => Ok(()),
Err(e) if e.is_unreachable() => {
warn_fallback(&home_url, &e);
let registry = registry_client(registry_url_override, &cfg, config_path)?;
run_list(&registry, &args, &mut stdout).await?;
Ok(())
}
Err(e) => Err(e.into()),
}
}
#[allow(clippy::result_large_err)]
async fn spawn(
config_path: &Path,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
args: SpawnArgs,
) -> Result<(), CliError> {
let cfg = CliConfig::load(config_path)?;
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override.clone());
let daemon = daemon_client(&home_url, config_path);
let attach_after = args.attach;
// `spawn --attach` has no detach-key flag; use the attach default (Ctrl-q).
let detach_key = 0x11;
// The home daemon now brokers cross-daemon create over the registry
// control link (NAP-327), so the only reason to fall back to the legacy
// registry HTTP path is the home daemon being unreachable.
let session = match run_spawn(&daemon, args.clone()).await {
Ok(s) => s,
Err(e) if e.is_unreachable() => {
warn_fallback(&home_url, &e);
let registry = registry_client(registry_url_override.clone(), &cfg, config_path)?;
run_spawn(&registry, args).await?
}
Err(e) => return Err(e.into()),
};
println!("spawned {}", session.id);
if attach_after {
attach_session(
&home_url,
registry_url_override,
&cfg,
config_path,
session.id,
detach_key,
)
.await?;
}
Ok(())
}
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
const CTRL_Q: u8 = 0x11;
/// HTTP client pointed at the daemon's API. That surface needs no auth —
/// reaching `bind_addr` is the authorization.
#[allow(clippy::result_large_err)]
async fn attach(
config_path: &Path,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
args: AttachArgs,
) -> Result<(), CliError> {
let cfg = CliConfig::load(config_path)?;
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override);
let detach = parse_detach_key(&args.detach_key)?;
attach_session(
&home_url,
registry_url_override,
&cfg,
config_path,
args.session_id,
detach,
)
.await
}
#[allow(clippy::result_large_err)]
async fn kill(
config_path: &Path,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
args: KillArgs,
) -> Result<(), CliError> {
let cfg = CliConfig::load(config_path)?;
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override);
let daemon = daemon_client(&home_url, config_path);
// The home daemon brokers cross-daemon kills too (NAP-327) and 404s only on
// a session no daemon knows about, so we no longer fall back on 404 — only
// when the home daemon itself is unreachable.
match run_kill(&daemon, &args).await {
Ok(()) => {}
Err(e) if e.is_unreachable() => {
warn_fallback(&home_url, &e);
let registry = registry_client(registry_url_override, &cfg, config_path)?;
run_kill(&registry, &args).await?;
}
Err(e) => return Err(e.into()),
}
println!("killed {}", args.session_id);
Ok(())
}
/// Attach to `session_id` through the home-daemon broker, falling back to the
/// registry's connect-info + direct WS path if the daemon is unreachable.
#[allow(clippy::result_large_err)]
async fn attach_session(
home_url: &str,
registry_url_override: Option<String>,
cfg: &CliConfig,
config_path: &Path,
session_id: uuid::Uuid,
detach: u8,
) -> Result<(), CliError> {
let broker_url = broker_attach_url(home_url, session_id)?;
match run_attach_url(&broker_url, detach).await {
Ok(()) => Ok(()),
Err(e) if e.is_unreachable() => {
eprintln!(
"[maestro] home daemon at {home_url} unreachable; \
falling back to the registry (legacy direct attach)"
);
let registry = registry_client(registry_url_override, cfg, config_path)?;
let info = fetch_connect_info(&registry, session_id).await?;
run_attach(info, session_id, detach).await?;
Ok(())
}
Err(e) => Err(e.into()),
}
}
/// HTTP client pointed at the home daemon's local API. The daemon's local
/// surface needs no auth (same-trust as `bind_addr`), so no cookie is sent.
fn daemon_client(home_url: &str, config_path: &Path) -> RegistryClient {
RegistryClient::new(home_url.to_string(), None, config_path.to_path_buf())
}
/// HTTP client pointed at the registry (legacy / fallback path).
#[allow(clippy::result_large_err)]
fn registry_client(
override_url: Option<String>,
cfg: &CliConfig,
config_path: &Path,
) -> Result<RegistryClient, CliError> {
let url = override_url
.or_else(|| cfg.registry_url.clone())
.ok_or(ConfigError::NoRegistryUrl)?;
Ok(RegistryClient::new(
url,
cfg.cookie.clone(),
config_path.to_path_buf(),
))
}
fn warn_fallback(home_url: &str, err: &HttpError) {
eprintln!(
"[maestro] home daemon at {home_url} unavailable ({err}); \
falling back to the registry"
);
fn client(config_path: &Path, override_url: Option<String>) -> Result<DaemonClient, CliError> {
let cfg = MaestroConfigFile::load(config_path)?;
Ok(DaemonClient::new(cfg.resolve_daemon_url(override_url)))
}
-212
View File
@@ -1,212 +0,0 @@
//! Thin HTTP client wrapper around `reqwest` that persists the
//! `maestro_session` cookie issued by the registry's cookie middleware.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use maestro_protocol::{extract_cookie, COOKIE_NAME};
use reqwest::header::{HeaderMap, HeaderValue, COOKIE, SET_COOKIE};
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::config::{CliConfig, ConfigError};
/// Time budget for establishing a TCP+TLS connection to the registry/daemon.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Total time budget for a single request (connect + send + receive). Bounds
/// the case where the peer is TCP-alive but never makes forward progress.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Build a `reqwest::Client` with the CLI's standard timeouts and user-agent.
///
/// Centralizing construction here guarantees every client the CLI uses gets a
/// connect *and* request timeout, so `maestro` never hangs forever on an
/// unhealthy registry or daemon (NAP-262).
pub fn build_client() -> reqwest::Client {
build_client_with_timeouts(CONNECT_TIMEOUT, REQUEST_TIMEOUT)
}
fn build_client_with_timeouts(connect: Duration, request: Duration) -> reqwest::Client {
reqwest::Client::builder()
.user_agent(concat!("maestro-cli/", env!("CARGO_PKG_VERSION")))
.connect_timeout(connect)
.timeout(request)
.build()
.expect("build reqwest client")
}
#[derive(Debug, thiserror::Error)]
pub enum HttpError {
#[error("http: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("server returned {status}: {body}")]
ServerError { status: StatusCode, body: String },
#[error("decode: {0}")]
Json(#[from] serde_json::Error),
#[error("config: {0}")]
Config(#[from] ConfigError),
#[error("invalid header: {0}")]
InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
}
impl HttpError {
/// True when the error is a transport-level failure to reach the host
/// (connection refused / connect error) rather than an HTTP-level
/// rejection. The CLI uses this to decide whether to fall back from the
/// home daemon to the legacy registry path (NAP-244).
pub fn is_unreachable(&self) -> bool {
matches!(self, HttpError::Reqwest(e) if e.is_connect())
}
}
/// HTTP client bound to a single registry. Persists the auth cookie back to
/// disk when the registry sets one (first-contact bootstrap from
/// `crates/maestro-registry/src/auth.rs`).
#[derive(Debug)]
pub struct RegistryClient {
base: String,
inner: reqwest::Client,
/// Cookie value (the JWT body), shared with the config-persister.
cookie: Arc<Mutex<Option<String>>>,
/// Where to write the config back when the cookie changes.
config_path: PathBuf,
}
impl RegistryClient {
pub fn new(base: String, cookie: Option<String>, config_path: PathBuf) -> Self {
let inner = build_client();
Self {
base: base.trim_end_matches('/').to_string(),
inner,
cookie: Arc::new(Mutex::new(cookie)),
config_path,
}
}
pub fn base(&self) -> &str {
&self.base
}
/// Snapshot of the current cookie (mostly for tests).
pub fn current_cookie(&self) -> Option<String> {
self.cookie.lock().unwrap().clone()
}
/// Send a request, attaching the persisted cookie. If the response sets a
/// new cookie (first contact, or a rotation), persist it back to disk.
async fn send(
&self,
method: Method,
path: &str,
body: Option<serde_json::Value>,
) -> Result<Response, HttpError> {
let url = format!("{}{}", self.base, path);
let mut headers = HeaderMap::new();
if let Some(c) = self.cookie.lock().unwrap().as_ref() {
headers.insert(
COOKIE,
HeaderValue::from_str(&format!("{COOKIE_NAME}={c}"))?,
);
}
let mut req = self.inner.request(method, &url).headers(headers);
if let Some(b) = body {
req = req.json(&b);
}
let resp = req.send().await?;
self.capture_cookie(resp.headers())?;
Ok(resp)
}
fn capture_cookie(&self, headers: &HeaderMap) -> Result<(), HttpError> {
let Some(raw) = headers.get(SET_COOKIE).and_then(|h| h.to_str().ok()) else {
return Ok(());
};
let Some(val) = extract_cookie(raw, COOKIE_NAME) else {
return Ok(());
};
let mut guard = self.cookie.lock().unwrap();
if guard.as_deref() == Some(val) {
return Ok(());
}
*guard = Some(val.to_string());
// Persist; on failure, log but don't abort the request.
if let Err(e) = CliConfig::save_cookie(&self.config_path, val) {
tracing::warn!(error = %e, path = %self.config_path.display(), "failed to persist cookie");
}
Ok(())
}
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, HttpError> {
let resp = self.send(Method::GET, path, None).await?;
ok_or_body(resp)
.await?
.json::<T>()
.await
.map_err(Into::into)
}
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, HttpError> {
let v = serde_json::to_value(body)?;
let resp = self.send(Method::POST, path, Some(v)).await?;
ok_or_body(resp)
.await?
.json::<T>()
.await
.map_err(Into::into)
}
pub async fn delete(&self, path: &str) -> Result<(), HttpError> {
let resp = self.send(Method::DELETE, path, None).await?;
ok_or_body(resp).await?;
Ok(())
}
}
async fn ok_or_body(resp: Response) -> Result<Response, HttpError> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let body = resp.text().await.unwrap_or_default();
Err(HttpError::ServerError { status, body })
}
#[cfg(test)]
mod tests {
use super::*;
/// A peer that accepts the TCP connection but never sends a response must
/// not hang the client forever: the request timeout has to fire (NAP-262).
#[tokio::test]
async fn request_timeout_fires_when_peer_never_responds() {
use std::io::Read;
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
// Accept one connection and sit on it without ever writing a response.
let handle = std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf);
std::thread::sleep(Duration::from_secs(2));
}
});
let client = build_client_with_timeouts(Duration::from_secs(5), Duration::from_millis(300));
let err = client
.get(format!("http://{addr}/"))
.send()
.await
.expect_err("request should time out, not hang");
assert!(err.is_timeout(), "expected timeout error, got: {err}");
let _ = handle.join();
}
}
+1 -6
View File
@@ -1,15 +1,10 @@
//! `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 dispatch;
pub mod http;
pub mod sessions;
pub use dispatch::{dispatch, CliError};
+81 -157
View File
@@ -1,45 +1,37 @@
//! `maestro list / spawn / kill` — registry HTTP calls + console formatting.
//! `maestro list / spawn / kill` — daemon HTTP calls + console formatting.
use std::collections::HashMap;
use std::io::Write;
use maestro_protocol::{
ActivityState, 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());
@@ -57,100 +49,73 @@ pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result<Sessi
};
let body = CreateSessionRequest {
daemon_id: args.daemon.clone(),
cwd: args.cwd.clone(),
command: args.command.clone(),
env,
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
pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), ClientError> {
client.kill_session(args.session_id).await
}
#[allow(clippy::write_literal)]
fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[DaemonInfo]) {
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
if sessions.is_empty() {
if daemons.is_empty() {
writeln!(out, "no sessions, no daemons connected").ok();
} else {
writeln!(
out,
"no sessions ({} daemon{} connected)",
daemons.len(),
if daemons.len() == 1 { "" } else { "s" }
)
.ok();
}
writeln!(out, "no sessions").ok();
return;
}
// Group by daemon, stable order.
let mut by_daemon: Vec<(String, Vec<&Session>)> = Vec::new();
for s in sessions {
if let Some(slot) = by_daemon.iter_mut().find(|(d, _)| d == &s.daemon_id) {
slot.1.push(s);
} else {
by_daemon.push((s.daemon_id.clone(), vec![s]));
}
}
for (_, group) in &mut by_daemon {
group.sort_by_key(|s| s.created_at);
}
let mut sorted: Vec<&Session> = sessions.iter().collect();
sorted.sort_by_key(|s| s.created_at);
for (daemon_id, group) in &by_daemon {
let daemon = daemons.iter().find(|d| &d.daemon_id == daemon_id);
let hostname = daemon.map(|d| d.hostname.as_str()).unwrap_or("?");
let home = daemon.map(|d| d.home_dir.as_str()).unwrap_or("");
writeln!(out, "\n{daemon_id} ({hostname})").ok();
if !info.hostname.is_empty() {
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} {:<9} {:<28} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
)
.ok();
for s in sorted {
// The pane's live cwd if observed, else the spawn dir, home-abbreviated.
let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), &info.home_dir);
let status = match s.status {
SessionStatus::Running => "running",
SessionStatus::Dead => "dead",
};
let activity = match s.activity {
ActivityState::Active => "active",
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
let command = match &s.foreground {
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
_ => s.command.clone(),
};
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
" {:<36} {:<8} {:<9} {:<28} {command}",
s.id, status, activity, dir
)
.ok();
for s in group {
// The pane's live cwd if observed, else the spawn dir, home-abbreviated.
let dir = abbreviate_dir(s.current_cwd.as_deref().unwrap_or(&s.cwd), home);
let status = match s.status {
SessionStatus::Running => "running",
SessionStatus::Dead => "dead",
};
let activity = match s.activity {
ActivityState::Active => "active",
ActivityState::Idle => "idle",
ActivityState::AwaitingInput => "awaiting",
};
// Show the live foreground command alongside the spawn command when
// the daemon has observed one that differs (NAP-340).
let command = match &s.foreground {
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
_ => s.command.clone(),
};
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {command}",
s.id, status, activity, dir
)
.ok();
}
}
}
@@ -175,13 +140,14 @@ mod tests {
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{ActivityState, DaemonInfo, Session, SessionStatus};
use maestro_protocol::{ActivityState, Session, SessionStatus};
use uuid::Uuid;
fn session(daemon: &str, cwd: &str, command: &str, id: u128) -> Session {
use super::DaemonInfo;
fn session(cwd: &str, command: &str, id: u128) -> Session {
Session {
id: Uuid::from_u128(id),
daemon_id: daemon.into(),
cwd: PathBuf::from(cwd),
command: command.into(),
env: HashMap::new(),
@@ -195,37 +161,27 @@ mod tests {
}
}
fn info(home: &str) -> DaemonInfo {
DaemonInfo {
hostname: "naps-laptop".into(),
home_dir: home.into(),
..Default::default()
}
}
#[test]
fn formats_grouped_table() {
fn formats_table() {
let sessions = vec![
session("laptop", "/home/naps/maestro", "claude", 1),
session("vm1", "/srv/work/api", "bash", 2),
session("laptop", "/home/naps/scratch", "nvim", 3),
];
let daemons = vec![
DaemonInfo {
daemon_id: "laptop".into(),
hostname: "naps-laptop".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.1:7070".into(),
home_dir: "/home/naps".into(),
},
DaemonInfo {
daemon_id: "vm1".into(),
hostname: "vm1.lan".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(),
home_dir: String::new(),
},
session("/home/naps/maestro", "claude", 1),
session("/srv/work/api", "bash", 2),
];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &sessions, &daemons);
super::print_session_table(&mut buf, &sessions, &info("/home/naps"));
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("laptop (naps-laptop)"), "{s}");
assert!(s.contains("vm1 (vm1.lan)"), "{s}");
assert!(s.contains("naps-laptop"), "{s}");
// Home dir collapses to ~ in the DIR column.
assert!(s.contains("~/maestro"), "{s}");
// No home dir → path shown verbatim.
// Outside the home dir → path shown verbatim.
assert!(s.contains("/srv/work/api"), "{s}");
assert!(s.contains("running"), "{s}");
assert!(s.contains("ACTIVITY"), "{s}");
@@ -235,35 +191,21 @@ mod tests {
#[test]
fn live_cwd_overrides_spawn_dir_in_table() {
let mut s = session("laptop", "/home/naps/maestro", "bash", 1);
let mut s = session("/home/naps/maestro", "bash", 1);
s.current_cwd = Some(PathBuf::from("/home/naps/maestro/crates/maestro-cli"));
let daemons = vec![DaemonInfo {
daemon_id: "laptop".into(),
hostname: "h".into(),
version: "0.1.0".into(),
daemon_url: "ws://x".into(),
home_dir: "/home/naps".into(),
}];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[s], &daemons);
super::print_session_table(&mut buf, &[s], &info("/home/naps"));
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("~/maestro/crates/maestro-cli"), "{out}");
}
#[test]
fn shows_activity_and_foreground() {
let mut s1 = session("laptop", "/tmp", "bash", 1);
let mut s1 = session("/tmp", "bash", 1);
s1.activity = ActivityState::AwaitingInput;
s1.foreground = Some("claude".into()); // differs from spawn command
let daemons = vec![DaemonInfo {
daemon_id: "laptop".into(),
hostname: "h".into(),
version: "0.1.0".into(),
daemon_url: "ws://x".into(),
home_dir: String::new(),
}];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[s1], &daemons);
super::print_session_table(&mut buf, &[s1], &info(""));
let out = String::from_utf8(buf).unwrap();
assert!(out.contains("awaiting"), "{out}");
// Live foreground surfaces next to the spawn command.
@@ -271,27 +213,9 @@ mod tests {
}
#[test]
fn empty_sessions_with_no_daemons() {
fn empty_sessions() {
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[], &[]);
assert_eq!(
String::from_utf8(buf).unwrap(),
"no sessions, no daemons connected\n"
);
}
#[test]
fn empty_sessions_with_daemons() {
let daemons = vec![DaemonInfo {
daemon_id: "laptop".into(),
hostname: "naps".into(),
version: "0.1.0".into(),
daemon_url: "ws://x".into(),
home_dir: String::new(),
}];
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[], &daemons);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("1 daemon connected"), "{s}");
super::print_session_table(&mut buf, &[], &info(""));
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
}
}
@@ -0,0 +1,107 @@
//! The CLI against a daemon. It had no such tests: `maestro list`'s formatting
//! was unit-tested, but nothing exercised the client, the error decoding, or
//! the unreachable-daemon path without compiling and forking a real daemon.
use maestro_protocol::{DaemonClient, DaemonInfo};
use maestro_testing::{session, MockDaemon};
use uuid::Uuid;
#[tokio::test]
async fn list_reads_the_daemon_catalog() {
let mock = MockDaemon::start().await;
mock.add_session(session(Uuid::from_u128(1), "/home/mock/work", "claude"));
let sessions = mock.client().list_sessions().await.expect("list");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].command, "claude");
}
#[tokio::test]
async fn info_carries_version_and_presets() {
let mock = MockDaemon::start().await;
mock.set_info(DaemonInfo {
hostname: "konishi".into(),
version: "9.9.9".into(),
home_dir: "/home/naps".into(),
presets: vec![maestro_protocol::SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
}],
});
let info = mock.client().daemon_info().await.expect("info");
// The CLI's own DaemonInfo used to drop both of these fields.
assert_eq!(info.version, "9.9.9");
assert_eq!(info.presets.len(), 1);
}
#[tokio::test]
async fn a_structured_failure_surfaces_the_daemon_message() {
let mock = MockDaemon::start().await;
mock.force_error(502, "tmux_failed", "no server running");
let err = mock
.client()
.list_sessions()
.await
.expect_err("should fail");
assert_eq!(
err.daemon_message(),
Some("no server running"),
"the daemon's own message must survive: {err}"
);
assert!(!err.is_unreachable());
}
#[tokio::test]
async fn an_unreachable_daemon_is_distinguishable_from_a_rejection() {
// Port 1 has nothing listening on it.
let client = DaemonClient::new("http://127.0.0.1:1");
let err = client.list_sessions().await.expect_err("should fail");
assert!(
err.is_unreachable(),
"connection refused must not read as an HTTP rejection: {err}"
);
}
#[tokio::test]
async fn killing_an_unknown_session_reports_the_daemon_reason() {
let mock = MockDaemon::start().await;
let err = mock
.client()
.kill_session(Uuid::new_v4())
.await
.expect_err("should fail");
assert_eq!(err.daemon_message(), Some("no such session on this daemon"));
}
#[tokio::test]
async fn spawn_round_trips_through_the_daemon() {
let mock = MockDaemon::start().await;
let created = mock
.client()
.create_session(&maestro_protocol::CreateSessionRequest {
cwd: "/home/mock".into(),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
})
.await
.expect("create");
assert_eq!(created.command, "bash");
assert_eq!(mock.session_count(), 1);
mock.client().kill_session(created.id).await.expect("kill");
assert_eq!(mock.session_count(), 0);
}
+1
View File
@@ -8,6 +8,7 @@ publish.workspace = true
[dependencies]
dirs.workspace = true
maestro-protocol.workspace = true
serde.workspace = true
thiserror.workspace = true
toml.workspace = true
+101 -421
View File
@@ -4,41 +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"
//! 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 {
@@ -50,16 +42,6 @@ pub enum ConfigError {
TomlEncode(#[from] toml::ser::Error),
#[error("could not determine config directory (XDG_CONFIG_HOME / $HOME unset?)")]
NoConfigDir,
#[error(
"maestro config not found at {}\n\
Run `maestrod configure` to create it. \
(Legacy ~/.config/maestrod/config.toml and ~/.config/maestro/{{cli,registry}}.toml \
are no longer read.)",
path.display()
)]
MissingFile { path: PathBuf },
#[error("invalid registry_url {input:?}: {reason}")]
InvalidRegistryUrl { input: String, reason: String },
#[error("invalid {env} ({input:?}): {source}")]
InvalidBindAddr {
env: &'static str,
@@ -69,100 +51,50 @@ pub enum ConfigError {
}
/// On-disk representation of the unified config. Every section is optional
/// so a partial file (e.g. just `[shared]` + `[registry]` for a registry
/// host) is valid.
#[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, skip_serializing_if = "TuiSection::is_empty")]
#[serde(default)]
pub tui: TuiSection,
#[serde(default, skip_serializing_if = "SpawnSection::is_empty")]
#[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>,
/// HTTP base URL of the home daemon the CLI brokers through (NAP-244),
/// e.g. `http://127.0.0.1:7070`. When unset, the CLI derives it from
/// HTTP base URL of the daemon the CLI talks to, e.g.
/// `http://127.0.0.1:7070`. When unset, the CLI derives it from
/// `[daemon].bind_addr` if a daemon is configured locally, else falls
/// back to `http://127.0.0.1:7070`. The legacy registry path stays
/// available as a compat fallback via `[shared].registry_url`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub home_daemon_url: Option<String>,
}
impl CliSection {
fn is_empty(&self) -> bool {
self.cookie.is_none() && self.home_daemon_url.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
/// Reachable URL clients dial for the daemon's per-session WS endpoint.
/// Distinct from `bind_addr` because the bind may be `0.0.0.0` while
/// the publicly reachable address is a Wireguard / VPN interface.
#[serde(default, skip_serializing_if = "Option::is_none")]
/// 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")]
#[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>,
/// `TMUX_TMPDIR` the daemon exports for every `tmux` invocation (NAP-234).
/// When unset, the daemon defaults to `$XDG_RUNTIME_DIR` so that bare
@@ -170,66 +102,27 @@ pub struct DaemonSection {
/// 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, skip_serializing_if = "Option::is_none")]
#[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, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub spawn_history_days: Option<u32>,
}
impl DaemonSection {
fn is_empty(&self) -> bool {
self.daemon_id.is_none()
&& self.hostname.is_none()
&& self.daemon_url.is_none()
&& self.bind_addr.is_none()
&& self.state_dir.is_none()
&& self.worktree_root.is_none()
&& self.perimeter_id.is_none()
&& self.private_addr.is_none()
&& self.pty_replay_bytes.is_none()
&& self.tmux_tmpdir.is_none()
&& self.spawn_history_days.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistrySection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bind_addr: Option<SocketAddr>,
}
impl RegistrySection {
fn is_empty(&self) -> bool {
self.bind_addr.is_none()
}
}
/// `[web]` — `maestro-web` binary settings (NAP-241).
#[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).
@@ -237,53 +130,40 @@ impl WebSection {
/// 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, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct TuiSection {
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub leader: Option<String>,
/// Maximum interval between chord keys before a held prefix is dropped.
/// Default `1000`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub chord_timeout_ms: Option<u64>,
/// Normal-mode bindings: canonical input → action string. Applied over
/// the shipped defaults.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[serde(default)]
pub keymap_normal: BTreeMap<String, String>,
/// Insert-mode bindings, same semantics as `keymap_normal`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[serde(default)]
pub keymap_insert: BTreeMap<String, String>,
}
impl TuiSection {
pub fn is_empty(&self) -> bool {
self.leader.is_none()
&& self.chord_timeout_ms.is_none()
&& self.keymap_normal.is_empty()
&& self.keymap_insert.is_empty()
}
}
/// `[spawn]` — command presets for the spawn form (#22, part of #21).
///
/// 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, Serialize, Deserialize)]
#[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, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub presets: Option<Vec<SpawnPreset>>,
}
impl SpawnSection {
fn is_empty(&self) -> bool {
self.presets.is_none()
}
/// The presets to show, in order, with the implicit `custom` tile appended.
///
/// Resolution: configured `presets` if present, else [`builtin_presets`];
@@ -305,12 +185,10 @@ impl SpawnSection {
/// 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.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnPreset {
pub id: String,
pub label: String,
pub command: String,
}
///
/// 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
@@ -387,47 +265,29 @@ fn validate_presets(presets: &[SpawnPreset]) -> Result<(), SpawnConfigError> {
}
impl MaestroConfigFile {
/// Strict load — error with [`ConfigError::MissingFile`] if absent.
/// Load the file, or return an empty config if it doesn't exist. Every
/// field has a default, so a missing file is never an error.
pub fn load(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Err(ConfigError::MissingFile {
path: path.to_path_buf(),
});
return Ok(Self::default());
}
let bytes = std::fs::read(path)?;
let s = std::str::from_utf8(&bytes).unwrap_or("");
Ok(toml::from_str(s)?)
}
/// Load if present, else return an empty config. Use for callers that
/// want a default-on-missing read (e.g. CLI's `set-registry` bootstrap,
/// registry's first-run token generation).
pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
Self::load(path)
/// 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())
}
/// 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).
@@ -436,34 +296,10 @@ pub fn default_config_path() -> Result<PathBuf, ConfigError> {
Ok(base.join(DIR_NAME).join(FILE_NAME))
}
/// Derive the daemon's WS endpoint from the shared `registry_url`.
///
/// | input | output |
/// |--------------------------|---------------------------------|
/// | `https://r.example` | `wss://r.example/daemon` |
/// | `http://r.example:8080` | `ws://r.example:8080/daemon` |
/// | `http://r/some/prefix/` | `ws://r/some/prefix/daemon` |
///
/// Trailing slashes are normalised so the result never has `//daemon`.
/// Any non-`http(s)` scheme returns [`ConfigError::InvalidRegistryUrl`].
pub fn derive_daemon_ws_url(registry_url: &str) -> Result<String, ConfigError> {
let trimmed = registry_url.trim_end_matches('/');
if let Some(rest) = trimmed.strip_prefix("https://") {
Ok(format!("wss://{rest}/daemon"))
} else if let Some(rest) = trimmed.strip_prefix("http://") {
Ok(format!("ws://{rest}/daemon"))
} else {
Err(ConfigError::InvalidRegistryUrl {
input: registry_url.to_string(),
reason: "must start with http:// or https://".into(),
})
}
}
/// Parse a `SocketAddr` from a raw (usually env-sourced) string, tagging the
/// failure with the originating env-var name so every binary surfaces the same
/// `invalid <ENV> (...)` diagnostic. Shared by the daemon, registry, and web
/// `bind_addr` resolution paths (NAP-273).
/// `invalid <ENV> (...)` diagnostic. Shared by the daemon and web `bind_addr`
/// resolution paths (NAP-273).
pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result<SocketAddr, ConfigError> {
raw.parse().map_err(|source| ConfigError::InvalidBindAddr {
env,
@@ -482,32 +318,10 @@ pub fn default_state_dir(subdir: &str) -> Result<PathBuf, ConfigError> {
.ok_or(ConfigError::NoConfigDir)?;
Ok(base.join(subdir))
}
/// Capacities for the daemon/registry broadcast channels (session events and
/// PTY byte fan-out). These were four independent `= 256` constants scattered
/// across the daemon and registry; centralizing them here makes the coincidence
/// explicit and gives a single home for per-deployment tuning later (NAP-273).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BroadcastTuning {
/// Session/registry event channels (`SessionEvent`, `RegistryEvent`).
pub events_capacity: usize,
/// Per-session PTY byte broadcast channel.
pub pty_capacity: usize,
}
impl BroadcastTuning {
/// The historical defaults — every channel was 256 by coincidence.
pub const DEFAULT: Self = Self {
events_capacity: 256,
pty_capacity: 256,
};
}
impl Default for BroadcastTuning {
fn default() -> Self {
Self::DEFAULT
}
}
/// 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 {
@@ -538,83 +352,19 @@ mod tests {
}
#[test]
fn broadcast_tuning_defaults_to_256() {
assert_eq!(BroadcastTuning::default(), BroadcastTuning::DEFAULT);
assert_eq!(BroadcastTuning::DEFAULT.events_capacity, 256);
assert_eq!(BroadcastTuning::DEFAULT.pty_capacity, 256);
fn broadcast_capacities_are_the_historical_defaults() {
assert_eq!(EVENTS_CAPACITY, 256);
assert_eq!(PTY_CAPACITY, 256);
}
#[test]
fn missing_file_strict_load_errors_with_pointer_to_configure() {
fn missing_file_loads_as_empty_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let err = MaestroConfigFile::load(&path).unwrap_err();
let msg = err.to_string();
assert!(matches!(err, ConfigError::MissingFile { .. }));
assert!(msg.contains("maestrod configure"), "msg was: {msg}");
assert!(msg.contains("Legacy"), "msg was: {msg}");
}
#[test]
fn missing_file_load_or_default_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = MaestroConfigFile::load_or_default(&path).unwrap();
let cfg = MaestroConfigFile::load(&path).unwrap();
assert_eq!(cfg, MaestroConfigFile::default());
}
#[test]
fn full_file_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = MaestroConfigFile {
shared: SharedSection {
registry_url: Some("https://r.example".into()),
token: Some("secret".into()),
},
cli: CliSection {
cookie: Some("eyJ...".into()),
home_daemon_url: Some("http://127.0.0.1:7070".into()),
},
daemon: DaemonSection {
daemon_id: Some("laptop".into()),
hostname: Some("laptop.lan".into()),
daemon_url: Some("ws://laptop.lan:7070".into()),
bind_addr: Some("127.0.0.1:7070".parse().unwrap()),
state_dir: Some(PathBuf::from("/var/lib/maestrod")),
worktree_root: Some(PathBuf::from("/var/lib/maestrod/wt")),
perimeter_id: Some("wg-naps62".into()),
private_addr: Some("10.0.0.7:7070".into()),
pty_replay_bytes: Some(131_072),
tmux_tmpdir: Some(PathBuf::from("/run/user/1000")),
spawn_history_days: Some(14),
},
registry: RegistrySection {
bind_addr: Some("0.0.0.0:8080".parse().unwrap()),
},
web: WebSection {
bind_addr: Some("127.0.0.1:8080".parse().unwrap()),
daemon_url: Some("http://127.0.0.1:7070".into()),
dev_proxy_target: Some("http://127.0.0.1:5173".into()),
},
tui: TuiSection {
leader: Some("ctrl-b".into()),
chord_timeout_ms: Some(750),
keymap_normal: BTreeMap::from([("i".into(), "ShowHelp".into())]),
keymap_insert: BTreeMap::new(),
},
spawn: SpawnSection {
presets: Some(vec![SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude --resume".into(),
}]),
},
};
cfg.save(&path).unwrap();
assert_eq!(MaestroConfigFile::load(&path).unwrap(), cfg);
}
#[test]
fn spawn_presets_default_to_builtins_plus_custom() {
// Absent [spawn].presets → built-ins, with the implicit custom tile last.
@@ -746,128 +496,58 @@ presets = [
assert_eq!(ids, ["claude", "zsh", "custom"]);
}
#[test]
fn save_omits_empty_spawn_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
MaestroConfigFile {
shared: SharedSection {
token: Some("t".into()),
..Default::default()
},
..Default::default()
}
.save(&path)
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(!body.contains("[spawn]"), "body: {body}");
}
#[test]
fn partial_file_loads_with_section_defaults() {
// Only [shared] + [registry] present (typical registry host).
// Only [daemon] present (typical single-host install).
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
token = "abc"
[registry]
[daemon]
bind_addr = "127.0.0.1:9090"
"#,
)
.unwrap();
let cfg = MaestroConfigFile::load(&path).unwrap();
assert_eq!(cfg.shared.token.as_deref(), Some("abc"));
assert!(cfg.shared.registry_url.is_none());
assert_eq!(cfg.cli, CliSection::default());
assert_eq!(cfg.daemon, DaemonSection::default());
assert_eq!(cfg.web, WebSection::default());
assert_eq!(
cfg.registry.bind_addr,
cfg.daemon.bind_addr,
Some("127.0.0.1:9090".parse().unwrap())
);
}
#[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");
}
}
+3 -6
View File
@@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Per-host tmux wrapper + session orchestrator that talks to the registry"
description = "Per-host tmux wrapper + session orchestrator"
[lib]
path = "src/lib.rs"
@@ -16,7 +16,6 @@ chrono.workspace = true
clap.workspace = true
dirs.workspace = true
futures-util.workspace = true
jsonwebtoken.workspace = true
libc.workspace = true
maestro-config.workspace = true
maestro-protocol.workspace = true
@@ -24,16 +23,14 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
urlencoding = "2"
uuid.workspace = true
[dev-dependencies]
reqwest.workspace = true
tokio-tungstenite.workspace = true
urlencoding = "2"
tempfile.workspace = true
maestro-registry = { path = "../maestro-registry" }
# `test-util` enables `start_paused` for instant timeout tests (NAP-270).
tokio = { workspace = true, features = ["test-util"] }
+11 -104
View File
@@ -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(&params.token) {
Ok(claims) => {
if claims.session_id != id {
return Err((
StatusCode::FORBIDDEN,
format!(
"token is for session {} but path requested {}",
claims.session_id, id
),
));
}
None
}
Err(e) => Some(e),
};
if let Some(legacy_err) = legacy_err {
match state.pair_verifier.verify(&params.token, id) {
Ok(sub) => {
tracing::debug!(home_daemon = %sub, %id, "pair-scoped attach accepted");
}
Err(pair_err) => {
// Both rejected — return the legacy error since most clients
// still mint that shape today.
return Err((
StatusCode::UNAUTHORIZED,
format!("legacy: {legacy_err}; pair: {pair_err}"),
));
}
}
}
if !state.manager.has(id).await {
return Err((StatusCode::NOT_FOUND, format!("no session {id}")));
}
let manager = state.manager.clone();
Ok(ws.on_upgrade(move |socket| async move {
let _ = serve_local(socket, manager, id).await;
}))
}
/// Pump a WebSocket against a local session's PTY. Reused by both the legacy
/// `/sessions/:id/stream` endpoint (after JWT auth) and the new broker
/// `WS /api/attach/:id` endpoint (when the session is local-owned — NAP-243).
pub(crate) async fn serve_local(
/// Pump a WebSocket against a session's PTY, behind `WS /api/attach/:id`.
///
/// 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
-822
View File
@@ -1,822 +0,0 @@
//! Home-daemon broker handle (NAP-247, extended in NAP-327).
//!
//! The HTTP handlers use this to issue correlated request/reply RPCs over the
//! daemon's single registry control link, keeping themselves thin:
//!
//! - `WS /api/attach/:id` → `resolve_attach`: resolve a peer-owned session into
//! an [`AttachCandidates`] (pair-scoped JWT + T2 perimeter info) (NAP-247).
//! - `POST /api/sessions` / `DELETE /api/sessions/:id` for a peer target →
//! `forward_create` / `forward_kill`: relay the op to the owning daemon and
//! await its result (NAP-327).
//!
//! Each call correlates its reply by `reply_to`, fails fast while the link is
//! down (NAP-263), and cleans up its pending entry on every exit path (NAP-259).
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use maestro_protocol::{
AttachCandidates, CreateSessionRequest, DaemonOutbound, DirListing, GitRoot, RecentDirs,
ReplyTo, RpcResult, Session,
};
use tokio::sync::{mpsc, oneshot};
use uuid::Uuid;
/// RAII cleanup for a pending-RPC entry. Removing the entry on drop guarantees
/// it is gone on *every* exit path of the awaiting call — success, error,
/// timeout, and crucially future cancellation (the axum handler is dropped when
/// the client hangs up) — so the map can't grow without bound (NAP-259).
///
/// The map is a `std::sync::Mutex` (never held across an await) precisely so the
/// sync `Drop` can lock it. Removal is idempotent: on the happy path the reply
/// handler has already taken the entry and this is a harmless no-op.
struct PendingGuard<'a, K: Eq + Hash, V> {
map: &'a Mutex<HashMap<K, V>>,
key: K,
}
impl<K: Eq + Hash, V> Drop for PendingGuard<'_, K, V> {
fn drop(&mut self) {
self.map.lock().unwrap().remove(&self.key);
}
}
/// How long to wait for a `ResolveAttachResult` after the request goes out.
/// Matches the registry's RPC timeout — a healthy registry replies in ms.
pub const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, thiserror::Error)]
pub enum BrokerError {
#[error("registry control link unavailable")]
NoControlLink,
#[error("registry reply channel closed before response")]
ReplyClosed,
#[error("registry did not reply within {0:?}")]
Timeout(Duration),
#[error("registry rejected resolve: {0}: {1}")]
Registry(String, String),
}
/// Shared between the registry-client task (producer of `ResolveAttachResult`)
/// and the broker HTTP handler (consumer). Clone freely — all fields are
/// behind `Arc`s.
#[derive(Debug, Clone)]
pub struct BrokerHandle {
out_tx: mpsc::Sender<DaemonOutbound>,
pending: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<AttachCandidates>>>>>,
/// Pending `ForwardCreate` replies, keyed by `reply_to` (NAP-327). Same
/// fail-fast + RAII-cleanup discipline as `pending`.
pending_create: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<Session>>>>>,
/// Pending `ForwardKill` replies, keyed by `reply_to` (NAP-327).
pending_kill: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<()>>>>>,
/// Pending `ForwardListDir` replies, keyed by `reply_to` (NAP-329).
pending_list_dir: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<DirListing>>>>>,
/// Pending `ForwardRecentDirs` replies, keyed by `reply_to` (#26).
pending_recent_dirs: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<RecentDirs>>>>>,
/// Pending `ForwardGitRoot` replies, keyed by `reply_to` (#26).
pending_git_root: Arc<Mutex<HashMap<ReplyTo, oneshot::Sender<RpcResult<GitRoot>>>>>,
/// Whether the registry control link is currently up. Flipped by the
/// registry-client task via [`BrokerHandle::set_connected`]. When down,
/// `resolve_attach` fails fast instead of queueing onto an undrained
/// channel that would otherwise fill and block every caller (NAP-263).
connected: Arc<AtomicBool>,
}
impl BrokerHandle {
/// `(handle, receiver)` — the registry-client task owns the receiver and
/// drains it into the control-link WS sink. The handle is shared with
/// HTTP handlers and is what they use to issue requests.
///
/// Starts **disconnected**: the registry-client task calls
/// [`set_connected(true)`](Self::set_connected) once the handshake lands.
pub fn new() -> (Self, mpsc::Receiver<DaemonOutbound>) {
let (out_tx, out_rx) = mpsc::channel(64);
let handle = Self {
out_tx,
pending: Arc::new(Mutex::new(HashMap::new())),
pending_create: Arc::new(Mutex::new(HashMap::new())),
pending_kill: Arc::new(Mutex::new(HashMap::new())),
pending_list_dir: Arc::new(Mutex::new(HashMap::new())),
pending_recent_dirs: Arc::new(Mutex::new(HashMap::new())),
pending_git_root: Arc::new(Mutex::new(HashMap::new())),
connected: Arc::new(AtomicBool::new(false)),
};
(handle, out_rx)
}
/// Mark the control link up/down. Called by the registry-client task on
/// every connect (`true`) and disconnect (`false`).
pub fn set_connected(&self, connected: bool) {
self.connected.store(connected, Ordering::Release);
}
/// Resolve a peer session: ask the registry for an [`AttachCandidates`].
pub async fn resolve_attach(&self, session_id: Uuid) -> Result<AttachCandidates, BrokerError> {
// Fail fast when the registry link is down — otherwise the bounded
// outbound channel (nothing draining it while disconnected) fills up
// and every subsequent caller blocks on `send().await` (NAP-263).
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(reply_to, tx);
// From here on, every exit path (including cancellation of this future)
// removes the pending entry when `_guard` drops (NAP-259).
let _guard = PendingGuard {
map: &*self.pending,
key: reply_to,
};
// Non-blocking send: a full channel (registry up but back-pressured)
// is treated as "link unavailable" rather than parking the caller.
if self
.out_tx
.try_send(DaemonOutbound::ResolveAttach {
reply_to,
session_id,
})
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { value })) => Ok(value),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Called by the registry-client task when a `ResolveAttachResult` frame
/// comes back from the registry. Resolves the waiting `resolve_attach`
/// future if one's still alive.
pub async fn complete_resolve(
&self,
reply_to: ReplyTo,
result: RpcResult<AttachCandidates>,
) -> bool {
let entry = self.pending.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ResolveAttachResult");
false
}
}
}
/// Broker a cross-daemon create: ask the registry to relay the request to
/// the owning daemon (`request.daemon_id`) and return the created session.
/// Same fail-fast-while-disconnected + RAII-cleanup discipline as
/// [`resolve_attach`](Self::resolve_attach) (NAP-263 / NAP-259, NAP-327).
pub async fn forward_create(
&self,
request: CreateSessionRequest,
) -> Result<Session, BrokerError> {
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending_create.lock().unwrap().insert(reply_to, tx);
let _guard = PendingGuard {
map: &*self.pending_create,
key: reply_to,
};
if self
.out_tx
.try_send(DaemonOutbound::ForwardCreate { reply_to, request })
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { value })) => Ok(value),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Broker a cross-daemon kill: ask the registry to relay the kill to the
/// daemon that owns `id` (NAP-327).
pub async fn forward_kill(&self, daemon_id: String, id: Uuid) -> Result<(), BrokerError> {
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending_kill.lock().unwrap().insert(reply_to, tx);
let _guard = PendingGuard {
map: &*self.pending_kill,
key: reply_to,
};
if self
.out_tx
.try_send(DaemonOutbound::ForwardKill {
reply_to,
daemon_id,
id,
})
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { .. })) => Ok(()),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Broker a cross-daemon directory listing: ask the registry to relay the
/// `ListDir` to the daemon named by `daemon_id` (which may be a peer) and
/// return its listing. Same fail-fast-while-disconnected + RAII-cleanup
/// discipline as the other broker calls (NAP-329).
pub async fn forward_list_dir(
&self,
daemon_id: String,
path: String,
) -> Result<DirListing, BrokerError> {
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending_list_dir.lock().unwrap().insert(reply_to, tx);
let _guard = PendingGuard {
map: &*self.pending_list_dir,
key: reply_to,
};
if self
.out_tx
.try_send(DaemonOutbound::ForwardListDir {
reply_to,
daemon_id,
path,
})
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { value })) => Ok(value),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Resolve a waiting [`forward_list_dir`](Self::forward_list_dir); called by
/// the registry-client task on a `ForwardListDirResult` frame.
pub async fn complete_forward_list_dir(
&self,
reply_to: ReplyTo,
result: RpcResult<DirListing>,
) -> bool {
let entry = self.pending_list_dir.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ForwardListDirResult");
false
}
}
}
/// Broker a cross-daemon recent-dirs query: ask the registry to relay a
/// `RecentDirs` request to the daemon named by `daemon_id` (which may be a
/// peer) and return its recents. Same discipline as the other broker calls
/// (#26).
pub async fn forward_recent_dirs(&self, daemon_id: String) -> Result<RecentDirs, BrokerError> {
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending_recent_dirs
.lock()
.unwrap()
.insert(reply_to, tx);
let _guard = PendingGuard {
map: &*self.pending_recent_dirs,
key: reply_to,
};
if self
.out_tx
.try_send(DaemonOutbound::ForwardRecentDirs {
reply_to,
daemon_id,
})
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { value })) => Ok(value),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Resolve a waiting [`forward_recent_dirs`](Self::forward_recent_dirs);
/// called by the registry-client task on a `ForwardRecentDirsResult` frame.
pub async fn complete_forward_recent_dirs(
&self,
reply_to: ReplyTo,
result: RpcResult<RecentDirs>,
) -> bool {
let entry = self.pending_recent_dirs.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ForwardRecentDirsResult");
false
}
}
}
/// Broker a cross-daemon gitroot probe: ask the registry to relay a
/// `GitRoot` request for `path` to the daemon named by `daemon_id` (which
/// may be a peer) and return its result (#26).
pub async fn forward_git_root(
&self,
daemon_id: String,
path: String,
) -> Result<GitRoot, BrokerError> {
if !self.connected.load(Ordering::Acquire) {
return Err(BrokerError::NoControlLink);
}
let reply_to = Uuid::new_v4();
let (tx, rx) = oneshot::channel();
self.pending_git_root.lock().unwrap().insert(reply_to, tx);
let _guard = PendingGuard {
map: &*self.pending_git_root,
key: reply_to,
};
if self
.out_tx
.try_send(DaemonOutbound::ForwardGitRoot {
reply_to,
daemon_id,
path,
})
.is_err()
{
return Err(BrokerError::NoControlLink);
}
match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await {
Ok(Ok(RpcResult::Ok { value })) => Ok(value),
Ok(Ok(RpcResult::Err { error })) => {
Err(BrokerError::Registry(error.code, error.message))
}
Ok(Err(_)) => Err(BrokerError::ReplyClosed),
Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)),
}
}
/// Resolve a waiting [`forward_git_root`](Self::forward_git_root); called by
/// the registry-client task on a `ForwardGitRootResult` frame.
pub async fn complete_forward_git_root(
&self,
reply_to: ReplyTo,
result: RpcResult<GitRoot>,
) -> bool {
let entry = self.pending_git_root.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ForwardGitRootResult");
false
}
}
}
/// Resolve a waiting [`forward_create`](Self::forward_create); called by the
/// registry-client task on a `ForwardCreateResult` frame.
pub async fn complete_forward_create(
&self,
reply_to: ReplyTo,
result: RpcResult<Session>,
) -> bool {
let entry = self.pending_create.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ForwardCreateResult");
false
}
}
}
/// Resolve a waiting [`forward_kill`](Self::forward_kill); called by the
/// registry-client task on a `ForwardKillResult` frame.
pub async fn complete_forward_kill(&self, reply_to: ReplyTo, result: RpcResult<()>) -> bool {
let entry = self.pending_kill.lock().unwrap().remove(&reply_to);
match entry {
Some(tx) => tx.send(result).is_ok(),
None => {
tracing::debug!(?reply_to, "no waiter for ForwardKillResult");
false
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Cancelling (dropping) an in-flight `resolve_attach` must not leak its
/// pending-reply entry — otherwise a browser hanging up mid-attach grows
/// the map without bound (NAP-259).
#[tokio::test]
async fn cancelled_resolve_attach_removes_pending_entry() {
// Keep the receiver alive so `out_tx.send()` succeeds and the call
// parks on the reply oneshot rather than failing fast.
let (handle, _out_rx) = BrokerHandle::new();
handle.set_connected(true);
{
let fut = handle.resolve_attach(Uuid::new_v4());
tokio::pin!(fut);
tokio::select! {
biased;
_ = &mut fut => panic!("resolve must not complete without a reply"),
_ = tokio::time::sleep(Duration::from_millis(20)) => {}
}
assert_eq!(
handle.pending.lock().unwrap().len(),
1,
"entry should be in-flight before cancellation"
);
}
assert_eq!(
handle.pending.lock().unwrap().len(),
0,
"pending entry must be removed on cancellation (NAP-259)"
);
}
/// NAP-263: while the registry link is down, `resolve_attach` must return
/// promptly instead of queueing onto the undrained outbound channel (which
/// would fill and deadlock callers). The receiver is alive with free
/// capacity here, so a `NoControlLink` proves the disconnected gate — not a
/// closed/full channel — short-circuited the call.
#[tokio::test]
async fn resolve_attach_fails_fast_while_disconnected() {
let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected
let res = tokio::time::timeout(
Duration::from_secs(1),
handle.resolve_attach(Uuid::new_v4()),
)
.await
.expect("resolve_attach must return promptly, not block on a full channel");
assert!(matches!(res, Err(BrokerError::NoControlLink)), "{res:?}");
// It must not leave a pending entry behind either.
assert_eq!(handle.pending.lock().unwrap().len(), 0);
}
/// NAP-270: a registry-reported error in the `ResolveAttachResult` reply is
/// surfaced as `BrokerError::Registry` carrying the registry's error code,
/// so the broker handler can map it to the right HTTP status.
#[tokio::test]
async fn broker_resolve_attach_propagates_registry_err() {
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task = tokio::spawn(async move { h.resolve_attach(Uuid::new_v4()).await });
// Learn the reply_to from the outbound frame, then deliver an error.
let reply_to = match out_rx.recv().await.expect("ResolveAttach went out") {
DaemonOutbound::ResolveAttach { reply_to, .. } => reply_to,
other => panic!("expected ResolveAttach, got {other:?}"),
};
handle
.complete_resolve(
reply_to,
RpcResult::Err {
error: maestro_protocol::ErrorInfo {
code: "unknown_session".into(),
message: "no daemon owns it".into(),
},
},
)
.await;
match task.await.unwrap() {
Err(BrokerError::Registry(code, _)) => assert_eq!(code, "unknown_session"),
other => panic!("expected Registry error, got {other:?}"),
}
}
// ----- NAP-327: forward_create / forward_kill -----
fn sample_request() -> CreateSessionRequest {
CreateSessionRequest {
daemon_id: "konishi".into(),
cwd: std::path::PathBuf::from("/tmp"),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}
}
/// A `forward_create` issues a `ForwardCreate` frame and resolves with the
/// session once the registry's `ForwardCreateResult` is delivered.
#[tokio::test]
async fn forward_create_round_trips_session() {
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task = tokio::spawn(async move { h.forward_create(sample_request()).await });
let reply_to = match out_rx.recv().await.expect("ForwardCreate went out") {
DaemonOutbound::ForwardCreate { reply_to, request } => {
assert_eq!(request.daemon_id, "konishi");
reply_to
}
other => panic!("expected ForwardCreate, got {other:?}"),
};
let session = Session {
id: Uuid::new_v4(),
daemon_id: "konishi".into(),
cwd: std::path::PathBuf::from("/tmp"),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
status: maestro_protocol::SessionStatus::Running,
created_at: chrono::Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
};
handle
.complete_forward_create(
reply_to,
RpcResult::Ok {
value: session.clone(),
},
)
.await;
let got = task.await.unwrap().expect("create ok");
assert_eq!(got.id, session.id);
}
/// While disconnected, `forward_create` / `forward_kill` fail fast with
/// `NoControlLink` and leave no pending entry (NAP-263 / NAP-259).
#[tokio::test]
async fn forward_ops_fail_fast_while_disconnected() {
let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected
assert!(matches!(
handle.forward_create(sample_request()).await,
Err(BrokerError::NoControlLink)
));
assert!(matches!(
handle.forward_kill("konishi".into(), Uuid::new_v4()).await,
Err(BrokerError::NoControlLink)
));
assert_eq!(handle.pending_create.lock().unwrap().len(), 0);
assert_eq!(handle.pending_kill.lock().unwrap().len(), 0);
}
/// A registry-reported error in the reply surfaces as `BrokerError::Registry`
/// carrying the code, so the HTTP handler can map it to a status.
#[tokio::test]
async fn forward_kill_propagates_registry_err() {
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task =
tokio::spawn(async move { h.forward_kill("konishi".into(), Uuid::new_v4()).await });
let reply_to = match out_rx.recv().await.expect("ForwardKill went out") {
DaemonOutbound::ForwardKill { reply_to, .. } => reply_to,
other => panic!("expected ForwardKill, got {other:?}"),
};
handle
.complete_forward_kill(
reply_to,
RpcResult::Err {
error: maestro_protocol::ErrorInfo {
code: "daemon_not_connected".into(),
message: "owner offline".into(),
},
},
)
.await;
match task.await.unwrap() {
Err(BrokerError::Registry(code, _)) => assert_eq!(code, "daemon_not_connected"),
other => panic!("expected Registry error, got {other:?}"),
}
}
// ----- NAP-329: forward_list_dir -----
/// A `forward_list_dir` issues a `ForwardListDir` frame and resolves with
/// the listing once the registry's `ForwardListDirResult` is delivered.
#[tokio::test]
async fn forward_list_dir_round_trips_listing() {
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task =
tokio::spawn(async move { h.forward_list_dir("konishi".into(), "/work".into()).await });
let reply_to = match out_rx.recv().await.expect("ForwardListDir went out") {
DaemonOutbound::ForwardListDir {
reply_to,
daemon_id,
path,
} => {
assert_eq!(daemon_id, "konishi");
assert_eq!(path, "/work");
reply_to
}
other => panic!("expected ForwardListDir, got {other:?}"),
};
handle
.complete_forward_list_dir(
reply_to,
RpcResult::Ok {
value: DirListing {
path: std::path::PathBuf::from("/work"),
entries: vec!["repo".into()],
truncated: false,
},
},
)
.await;
let got = task.await.unwrap().expect("list ok");
assert_eq!(got.entries, vec!["repo".to_string()]);
}
/// While disconnected, `forward_list_dir` fails fast and leaves no pending
/// entry (NAP-263 / NAP-259).
#[tokio::test]
async fn forward_list_dir_fails_fast_while_disconnected() {
let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected
assert!(matches!(
handle
.forward_list_dir("konishi".into(), "/work".into())
.await,
Err(BrokerError::NoControlLink)
));
assert_eq!(handle.pending_list_dir.lock().unwrap().len(), 0);
}
// ----- #26: forward_recent_dirs / forward_git_root -----
/// `forward_recent_dirs` issues a `ForwardRecentDirs` frame and resolves
/// with the recents once the registry's `ForwardRecentDirsResult` lands.
#[tokio::test]
async fn forward_recent_dirs_round_trips() {
use maestro_protocol::{RecentDir, RecentDirs};
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task = tokio::spawn(async move { h.forward_recent_dirs("konishi".into()).await });
let reply_to = match out_rx.recv().await.expect("ForwardRecentDirs went out") {
DaemonOutbound::ForwardRecentDirs {
reply_to,
daemon_id,
} => {
assert_eq!(daemon_id, "konishi");
reply_to
}
other => panic!("expected ForwardRecentDirs, got {other:?}"),
};
handle
.complete_forward_recent_dirs(
reply_to,
RpcResult::Ok {
value: RecentDirs {
dirs: vec![RecentDir {
cwd: std::path::PathBuf::from("/work"),
last_command: "claude".into(),
last_used: chrono::Utc::now(),
}],
truncated: false,
},
},
)
.await;
let got = task.await.unwrap().expect("recents ok");
assert_eq!(got.dirs.len(), 1);
assert_eq!(got.dirs[0].cwd, std::path::PathBuf::from("/work"));
}
/// `forward_git_root` issues a `ForwardGitRoot` frame carrying the path and
/// resolves with the probe result.
#[tokio::test]
async fn forward_git_root_round_trips() {
use maestro_protocol::GitRoot;
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task = tokio::spawn(async move {
h.forward_git_root("konishi".into(), "/work/sub".into())
.await
});
let reply_to = match out_rx.recv().await.expect("ForwardGitRoot went out") {
DaemonOutbound::ForwardGitRoot {
reply_to,
daemon_id,
path,
} => {
assert_eq!(daemon_id, "konishi");
assert_eq!(path, "/work/sub");
reply_to
}
other => panic!("expected ForwardGitRoot, got {other:?}"),
};
handle
.complete_forward_git_root(
reply_to,
RpcResult::Ok {
value: GitRoot {
root: Some(std::path::PathBuf::from("/work")),
},
},
)
.await;
let got = task.await.unwrap().expect("gitroot ok");
assert_eq!(got.root, Some(std::path::PathBuf::from("/work")));
}
/// While disconnected, both new broker calls fail fast and leave no pending
/// entry (NAP-263 / NAP-259).
#[tokio::test]
async fn forward_recents_and_gitroot_fail_fast_while_disconnected() {
let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected
assert!(matches!(
handle.forward_recent_dirs("konishi".into()).await,
Err(BrokerError::NoControlLink)
));
assert!(matches!(
handle.forward_git_root("konishi".into(), "/x".into()).await,
Err(BrokerError::NoControlLink)
));
assert_eq!(handle.pending_recent_dirs.lock().unwrap().len(), 0);
assert_eq!(handle.pending_git_root.lock().unwrap().len(), 0);
}
/// NAP-270: if the registry never replies, `resolve_attach` resolves to
/// `BrokerError::Timeout` after `RESOLVE_TIMEOUT` (driven here with a paused
/// clock so the test is instant).
#[tokio::test(start_paused = true)]
async fn broker_resolve_attach_times_out_when_no_reply() {
let (handle, mut out_rx) = BrokerHandle::new();
handle.set_connected(true);
let h = handle.clone();
let task = tokio::spawn(async move { h.resolve_attach(Uuid::new_v4()).await });
// The request is out and the call is parked on the reply oneshot.
let _ = out_rx.recv().await.expect("ResolveAttach went out");
// Never reply; jump past the RPC timeout.
tokio::time::advance(RESOLVE_TIMEOUT + Duration::from_secs(1)).await;
match task.await.unwrap() {
Err(BrokerError::Timeout(_)) => {}
other => panic!("expected Timeout, got {other:?}"),
}
}
}
-201
View File
@@ -1,201 +0,0 @@
//! Daemon-local mirror of the registry catalog (NAP-246).
//!
//! The registry fans out a stream of `RegistryEvent`s to each connected
//! daemon, filtered so each one only sees *peer* state. This module owns the
//! resulting in-memory copy and exposes read snapshots to the daemon's HTTP
//! handlers (`GET /api/sessions`, `GET /api/daemons`).
//!
//! The mirror does *not* include the daemon's own sessions — those come from
//! `SessionManager`. Combining the two for HTTP responses is the caller's
//! job (see `local_api.rs`).
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use maestro_protocol::{DaemonInfo, RegistryEvent, Session};
use tokio::sync::Mutex;
use uuid::Uuid;
#[derive(Debug, Default)]
struct State {
daemons: HashMap<String, DaemonInfo>,
/// `(daemon_id, session_id) -> Session` — mirrors the registry's catalog
/// keying so removals (which arrive as `(daemon_id, id)`) are O(1).
sessions: BTreeMap<(String, Uuid), Session>,
}
#[derive(Debug, Clone, Default)]
pub struct CatalogMirror {
inner: Arc<Mutex<State>>,
/// Optional fan-out (NAP-248). When attached, every applied event is
/// re-published here so `WS /api/events` subscribers see peer state
/// changes the same way the registry's clients do.
bus: Option<crate::local_events::LocalEventBus>,
}
impl CatalogMirror {
pub fn new() -> Self {
Self::default()
}
/// Attach a [`LocalEventBus`](crate::local_events::LocalEventBus) so peer
/// catalog deltas reach the daemon's `/api/events` subscribers.
pub fn with_bus(mut self, bus: crate::local_events::LocalEventBus) -> Self {
self.bus = Some(bus);
self
}
/// Apply one delta. Snapshot events replace the mirror wholesale;
/// `Lagged` resets it on the assumption that the next full snapshot will
/// arrive later (today the registry doesn't re-send one — see follow-up
/// notes in `handler.rs`).
pub async fn apply(&self, event: RegistryEvent) {
{
let mut s = self.inner.lock().await;
match &event {
RegistryEvent::Snapshot { daemons, sessions } => {
s.daemons.clear();
s.sessions.clear();
for d in daemons {
s.daemons.insert(d.daemon_id.clone(), d.clone());
}
for sess in sessions {
s.sessions
.insert((sess.daemon_id.clone(), sess.id), sess.clone());
}
}
RegistryEvent::DaemonAdded { daemon } => {
s.daemons.insert(daemon.daemon_id.clone(), daemon.clone());
}
RegistryEvent::DaemonRemoved { daemon_id } => {
s.daemons.remove(daemon_id);
s.sessions.retain(|(d, _), _| d != daemon_id);
}
RegistryEvent::SessionAdded { session }
| RegistryEvent::SessionUpdated { session } => {
s.sessions
.insert((session.daemon_id.clone(), session.id), session.clone());
}
RegistryEvent::SessionRemoved { daemon_id, id } => {
s.sessions.remove(&(daemon_id.clone(), *id));
}
RegistryEvent::Lagged { skipped } => {
tracing::warn!(
skipped = *skipped,
"catalog mirror lagged — local view may be stale"
);
}
}
}
// Re-publish AFTER releasing the lock so subscribers reading `daemons()`
// / `sessions()` in response observe the new state.
if let Some(bus) = &self.bus {
bus.publish(event);
}
}
/// Snapshot the peer daemons currently mirrored.
pub async fn daemons(&self) -> Vec<DaemonInfo> {
self.inner.lock().await.daemons.values().cloned().collect()
}
/// Snapshot every peer session currently mirrored.
pub async fn sessions(&self) -> Vec<Session> {
self.inner.lock().await.sessions.values().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use maestro_protocol::SessionStatus;
use std::collections::HashMap as StdHashMap;
fn peer_session(daemon: &str, id_lo: u128) -> Session {
Session {
id: Uuid::from_u128(id_lo),
daemon_id: daemon.into(),
cwd: "/tmp".into(),
command: "bash".into(),
env: StdHashMap::new(),
metadata: StdHashMap::new(),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
fn peer_daemon(daemon_id: &str) -> DaemonInfo {
DaemonInfo {
daemon_id: daemon_id.into(),
hostname: format!("{daemon_id}-host"),
version: "0.1.0".into(),
daemon_url: format!("ws://{daemon_id}:7070"),
home_dir: String::new(),
}
}
#[tokio::test]
async fn snapshot_replaces_state() {
let m = CatalogMirror::new();
m.apply(RegistryEvent::DaemonAdded {
daemon: peer_daemon("old"),
})
.await;
m.apply(RegistryEvent::Snapshot {
daemons: vec![peer_daemon("new")],
sessions: vec![peer_session("new", 1)],
})
.await;
let ds = m.daemons().await;
assert_eq!(ds.len(), 1);
assert_eq!(ds[0].daemon_id, "new");
assert_eq!(m.sessions().await.len(), 1);
}
#[tokio::test]
async fn daemon_removed_cascades_to_sessions() {
let m = CatalogMirror::new();
m.apply(RegistryEvent::DaemonAdded {
daemon: peer_daemon("konishi"),
})
.await;
m.apply(RegistryEvent::SessionAdded {
session: peer_session("konishi", 1),
})
.await;
m.apply(RegistryEvent::SessionAdded {
session: peer_session("konishi", 2),
})
.await;
assert_eq!(m.sessions().await.len(), 2);
m.apply(RegistryEvent::DaemonRemoved {
daemon_id: "konishi".into(),
})
.await;
assert!(m.daemons().await.is_empty());
assert!(m.sessions().await.is_empty());
}
#[tokio::test]
async fn session_update_replaces_in_place() {
let m = CatalogMirror::new();
m.apply(RegistryEvent::SessionAdded {
session: peer_session("konishi", 1),
})
.await;
let mut updated = peer_session("konishi", 1);
updated.status = SessionStatus::Dead;
m.apply(RegistryEvent::SessionUpdated { session: updated })
.await;
let after = m.sessions().await;
assert_eq!(after.len(), 1);
assert_eq!(after[0].status, SessionStatus::Dead);
}
}
+9 -109
View File
@@ -1,8 +1,5 @@
//! Clap-derive subcommand for the daemon, plus a runner that wires together
//! everything the binary used to do in `main.rs`.
//!
//! Lifted out of the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
//! binary can compose this subcommand alongside the CLI and registry.
//! Clap-derive subcommand for the daemon, plus a runner that wires everything
//! together.
use std::error::Error;
use std::path::PathBuf;
@@ -15,52 +12,13 @@ use crate::sidecar::SidecarStore;
use crate::tmux::Tmux;
use crate::worktree::WorktreeManager;
use crate::{
attach, default_config_path, default_state_dir, load_config, local_api, run_configure,
spawn_session_publisher, ConfigureInputs, EnvOverrides, JwtVerifier, LocalEventBus,
PairAttachVerifier, RegistryClient, SessionManager,
default_config_path, default_state_dir, load_config, local_api, EnvOverrides, SessionManager,
};
#[derive(Debug, Subcommand)]
pub enum DaemonCmd {
/// Start the daemon. Default when no subcommand is given.
Run,
/// Write `~/.config/maestro/config.toml` interactively or from flags.
Configure(ConfigureArgs),
}
#[derive(Debug, clap::Args)]
pub struct ConfigureArgs {
/// Registry base URL (e.g. `https://maestro.example.com`). The daemon
/// derives its WS endpoint by scheme-swapping and appending `/daemon`.
#[arg(long)]
pub registry_url: Option<String>,
/// Reachable URL clients dial for this daemon's attach WS.
#[arg(long)]
pub daemon_url: Option<String>,
/// Shared token (MAESTRO_TOKEN). Tip: pipe from `pass` / `op` / `secret-tool`.
#[arg(long)]
pub token: Option<String>,
/// Stable daemon ID. Defaults to the system hostname.
#[arg(long)]
pub daemon_id: Option<String>,
/// Local bind address for the per-session WS endpoint.
#[arg(long)]
pub bind_addr: Option<String>,
/// Override the daemon's state directory.
#[arg(long)]
pub state_dir: Option<PathBuf>,
/// Override the worktree root.
#[arg(long)]
pub worktree_root: Option<PathBuf>,
/// Override the recorded hostname (defaults to `gethostname(2)`).
#[arg(long)]
pub hostname: Option<String>,
/// Overwrite an existing config file.
#[arg(long)]
pub force: bool,
/// Disable interactive prompts (errors if any required field is missing).
#[arg(long)]
pub non_interactive: bool,
}
/// Dispatch a parsed daemon subcommand. `config_override` is the unified
@@ -71,7 +29,6 @@ pub async fn dispatch(
) -> Result<(), Box<dyn Error>> {
match cmd.unwrap_or(DaemonCmd::Run) {
DaemonCmd::Run => run(config_override).await,
DaemonCmd::Configure(args) => configure(config_override, args),
}
}
@@ -82,8 +39,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
};
let config = load_config(&config_path, EnvOverrides::from_env())?;
tracing::info!(
daemon_id = %config.daemon_id,
registry = %config.registry_url,
hostname = %config.hostname,
bind = %config.bind_addr,
"loaded config"
);
@@ -100,8 +56,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
None => Tmux::new(),
};
let manager = SessionManager::new_with_replay(
config.daemon_id.clone(),
tmux,
std::sync::Arc::new(tmux),
sidecar,
worktree_mgr,
config.fifo_dir(),
@@ -116,51 +71,16 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
tracing::info!(recovered, "adopted existing tmux sessions");
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
let verifier = JwtVerifier::new(&config.token, &config.daemon_id);
let pair_verifier = PairAttachVerifier::new(&config.token, &config.daemon_id);
let events = LocalEventBus::new();
spawn_session_publisher(manager.clone(), events.clone());
let client = RegistryClient::new_with_bus(
config.registry_url.clone(),
config.daemon_id.clone(),
let router = local_api::router(local_api::state_for(
manager,
config.hostname.clone(),
config.daemon_url.clone(),
config.home_dir.clone(),
config.token.clone(),
manager.clone(),
Some(events.clone()),
)
.with_perimeter(config.perimeter());
let catalog = client.catalog();
let local_state = local_api::state_for(
manager.clone(),
config.hostname.clone(),
config.daemon_url.clone(),
config.home_dir.clone(),
config.spawn_presets.clone(),
catalog.clone(),
events,
client.broker(),
);
let broker_state = local_api::BrokerState {
manager: manager.clone(),
catalog,
broker: client.broker(),
self_daemon_id: std::sync::Arc::new(config.daemon_id.clone()),
self_perimeter_id: config.perimeter_id.clone().map(std::sync::Arc::new),
};
let router = attach::router(manager.clone(), verifier, pair_verifier)
.merge(local_api::router(local_state))
.merge(local_api::broker_router(broker_state));
));
let listener = TcpListener::bind(config.bind_addr).await?;
let bound = listener.local_addr()?;
tracing::info!(addr = %bound, "attach WS listening");
tokio::spawn(async move { client.run().await });
tracing::info!(addr = %bound, "API listening");
let shutdown = async {
let _ = tokio::signal::ctrl_c().await;
@@ -172,23 +92,3 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
.await?;
Ok(())
}
fn configure(config_override: Option<PathBuf>, args: ConfigureArgs) -> Result<(), Box<dyn Error>> {
let path: PathBuf = match config_override {
Some(p) => p,
None => default_config_path()?,
};
let inputs = ConfigureInputs {
registry_url: args.registry_url,
daemon_url: args.daemon_url,
token: args.token,
daemon_id: args.daemon_id,
bind_addr: args.bind_addr,
state_dir: args.state_dir,
worktree_root: args.worktree_root,
hostname: args.hostname,
};
let written = run_configure(&path, inputs, !args.non_interactive, args.force)?;
eprintln!("wrote {}", written.display());
Ok(())
}
+22 -311
View File
@@ -1,27 +1,20 @@
//! Daemon view over the unified Maestro config.
//!
//! Reads `[shared]` (registry_url, token) and `[daemon]` (daemon_id,
//! hostname, daemon_url, bind_addr, state_dir, worktree_root) from
//! `~/.config/maestro/config.toml`.
//! Reads `[daemon]` (hostname, bind_addr, state_dir, worktree_root, …) and
//! `[spawn]` from `~/.config/maestro/config.toml`.
//!
//! Precedence per field: **env > file > default**. The daemon refuses
//! to start if `registry_url`, `daemon_url`, or `token` are absent
//! from both env and file.
//! Precedence per field: **env > file > default**. Every field has a default,
//! so a missing config file is fine — the daemon starts with defaults.
//!
//! Default config path: `~/.config/maestro/config.toml` (override via
//! `$MAESTRO_CONFIG`). If the file is missing entirely the daemon
//! errors with a pointer at `maestrod configure` — legacy
//! `~/.config/maestrod/config.toml` is no longer read.
//! `$MAESTRO_CONFIG`).
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use maestro_config::{derive_daemon_ws_url, MaestroConfigFile};
use maestro_protocol::PerimeterCandidate;
use maestro_config::MaestroConfigFile;
pub use maestro_config::{
default_config_path, resolve_config_path, DaemonSection, SharedSection, CONFIG_PATH_ENV,
};
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";
@@ -30,20 +23,8 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070";
/// `[daemon].spawn_history_days` is unset. `0` in config disables history.
pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30;
/// Env var that overrides `[shared].registry_url`.
pub const ENV_REGISTRY_URL: &str = "MAESTRO_REGISTRY_URL";
/// Env var that overrides `[shared].token`.
pub const ENV_TOKEN: &str = "MAESTRO_TOKEN";
/// Env var that overrides `[daemon].daemon_id`.
pub const ENV_DAEMON_ID: &str = "MAESTRO_DAEMON_ID";
/// Env var that overrides `[daemon].daemon_url`.
pub const ENV_DAEMON_URL: &str = "MAESTRO_DAEMON_URL";
/// Env var that overrides `[daemon].bind_addr`.
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
/// Env var that overrides `[daemon].perimeter_id`.
pub const ENV_DAEMON_PERIMETER_ID: &str = "MAESTRO_DAEMON_PERIMETER_ID";
/// Env var that overrides `[daemon].private_addr`.
pub const ENV_DAEMON_PRIVATE_ADDR: &str = "MAESTRO_DAEMON_PRIVATE_ADDR";
/// Env var that overrides `[daemon].tmux_tmpdir`.
pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR";
@@ -57,12 +38,6 @@ pub enum ConfigError {
Inner(#[from] maestro_config::ConfigError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("missing required config field `{field}` — set it in {path:?} or via env var `{env}`")]
Missing {
field: &'static str,
env: &'static str,
path: PathBuf,
},
#[error("invalid [spawn].presets: {0}")]
SpawnPresets(#[from] maestro_config::SpawnConfigError),
}
@@ -70,26 +45,15 @@ pub enum ConfigError {
/// Resolved, validated daemon configuration ready to drive the binary.
#[derive(Debug, Clone)]
pub struct DaemonConfig {
pub daemon_id: String,
/// Hostname advertised to clients, for display only.
pub hostname: String,
/// Home directory of the OS user the daemon runs as, advertised to clients
/// so they can prefill a spawn form's working directory. Empty when `$HOME`
/// can't be resolved (NAP-328).
pub home_dir: String,
/// Registry WS URL the daemon dials. Derived from `[shared].registry_url`
/// via [`maestro_config::derive_daemon_ws_url`].
pub registry_url: String,
pub bind_addr: SocketAddr,
pub daemon_url: String,
pub token: String,
pub state_dir: PathBuf,
pub worktree_root: PathBuf,
/// Operator-set perimeter identifier. Advertised to the registry in
/// heartbeats so peers can dial this daemon over T2 when they share it.
pub perimeter_id: Option<String>,
/// Daemon's reachable address on the declared perimeter. Only meaningful
/// when `perimeter_id` is set.
pub private_addr: Option<String>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
/// receives this much of the prior PTY output before the live stream.
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
@@ -104,8 +68,8 @@ pub struct DaemonConfig {
/// [`DEFAULT_SPAWN_HISTORY_DAYS`].
pub spawn_history_days: u32,
/// Resolved spawn-form command presets (#22/#26): `[spawn].presets` (or the
/// built-ins) with the implicit `custom` tile appended. Advertised to the
/// web in `GET /api/web/info`.
/// built-ins) with the implicit `custom` tile appended. Advertised to
/// clients in `GET /api/info`.
pub spawn_presets: Vec<maestro_config::SpawnPreset>,
}
@@ -122,43 +86,13 @@ impl DaemonConfig {
pub fn spawn_history_path(&self) -> PathBuf {
self.state_dir.join("spawn-history.jsonl")
}
/// Combine the two perimeter config keys into the typed advertisement the
/// daemon broadcasts. A perimeter is only advertised when BOTH the id and
/// the private address are set — this is the single place the "both or
/// neither" invariant lives (NAP-272). A half-configured pair is dropped
/// with a warning rather than silently advertising a useless value.
pub fn perimeter(&self) -> Option<PerimeterCandidate> {
match (self.perimeter_id.clone(), self.private_addr.clone()) {
(Some(perimeter_id), Some(private_addr)) => Some(PerimeterCandidate {
perimeter_id,
private_addr,
}),
(None, None) => None,
(id, addr) => {
tracing::warn!(
perimeter_id = ?id,
private_addr = ?addr,
"ignoring half-configured perimeter — set both [daemon].perimeter_id and \
[daemon].private_addr, or neither"
);
None
}
}
}
}
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
/// `Option`s; production reads from real env vars in `main`.
#[derive(Debug, Clone, Default)]
pub struct EnvOverrides {
pub daemon_id: Option<String>,
pub registry_url: Option<String>,
pub bind_addr: Option<String>,
pub daemon_url: Option<String>,
pub token: Option<String>,
pub perimeter_id: Option<String>,
pub private_addr: Option<String>,
pub tmux_tmpdir: Option<String>,
}
@@ -166,40 +100,17 @@ impl EnvOverrides {
/// Read every supported env var from the process environment.
pub fn from_env() -> Self {
Self {
daemon_id: std::env::var(ENV_DAEMON_ID).ok(),
registry_url: std::env::var(ENV_REGISTRY_URL).ok(),
bind_addr: std::env::var(ENV_DAEMON_BIND_ADDR).ok(),
daemon_url: std::env::var(ENV_DAEMON_URL).ok(),
token: std::env::var(ENV_TOKEN).ok(),
perimeter_id: std::env::var(ENV_DAEMON_PERIMETER_ID).ok(),
private_addr: std::env::var(ENV_DAEMON_PRIVATE_ADDR).ok(),
tmux_tmpdir: std::env::var(ENV_DAEMON_TMUX_TMPDIR).ok(),
}
}
}
/// Resolve the runtime configuration. Applies env overrides on top of the
/// unified file, fills defaults, validates the three required fields, and
/// derives the daemon's WS endpoint from the shared registry URL.
///
/// Returns [`maestro_config::ConfigError::MissingFile`] (wrapped) when the
/// file is absent and not all required fields come from env vars — this is
/// the hard-cutover error that points the operator at `maestrod configure`.
/// Resolve the runtime configuration: env overrides on top of the unified
/// file, then defaults. A missing file is not an error.
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, ConfigError> {
let file = if path.exists() {
MaestroConfigFile::load(path)?
} else if env_has_all_required(&env) {
// Container / systemd path: every required field comes from env,
// so the on-disk file is genuinely optional.
MaestroConfigFile::default()
} else {
return Err(maestro_config::ConfigError::MissingFile {
path: path.to_path_buf(),
}
.into());
};
let file = MaestroConfigFile::load(path)?;
let shared = file.shared;
let daemon = file.daemon;
// Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id,
// empty command) refuses startup with a clear error, like other config
@@ -210,32 +121,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let daemon_id = env
.daemon_id
.or(daemon.daemon_id)
.unwrap_or_else(|| hostname.clone());
let registry_url = derive_daemon_ws_url(&env.registry_url.or(shared.registry_url).ok_or(
ConfigError::Missing {
field: "shared.registry_url",
env: ENV_REGISTRY_URL,
path: path.to_path_buf(),
},
)?)?;
let daemon_url = env
.daemon_url
.or(daemon.daemon_url)
.ok_or(ConfigError::Missing {
field: "daemon.daemon_url",
env: ENV_DAEMON_URL,
path: path.to_path_buf(),
})?;
let token = env.token.or(shared.token).ok_or(ConfigError::Missing {
field: "shared.token",
env: ENV_TOKEN,
path: path.to_path_buf(),
})?;
let bind_addr = match env.bind_addr {
Some(raw) => maestro_config::parse_bind_addr(&raw, ENV_DAEMON_BIND_ADDR)?,
@@ -250,8 +135,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
.worktree_root
.unwrap_or_else(|| state_dir.join("worktrees"));
let perimeter_id = env.perimeter_id.or(daemon.perimeter_id);
let private_addr = env.private_addr.or(daemon.private_addr);
let pty_replay_bytes = daemon
.pty_replay_bytes
.unwrap_or(crate::pty::DEFAULT_REPLAY_BYTES);
@@ -265,17 +148,11 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
Ok(DaemonConfig {
daemon_id,
hostname,
home_dir,
registry_url,
bind_addr,
daemon_url,
token,
state_dir,
worktree_root,
perimeter_id,
private_addr,
pty_replay_bytes,
tmux_tmpdir,
spawn_history_days,
@@ -283,10 +160,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
})
}
fn env_has_all_required(env: &EnvOverrides) -> bool {
env.registry_url.is_some() && env.daemon_url.is_some() && env.token.is_some()
}
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
Ok(maestro_config::default_state_dir("maestro")?)
}
@@ -307,60 +180,27 @@ pub fn system_hostname() -> String {
mod tests {
use super::*;
fn minimal_file() -> &'static str {
r#"
[shared]
registry_url = "https://r.example"
token = "abc"
[daemon]
daemon_url = "ws://localhost:7070"
"#
}
#[test]
fn minimal_file_defaults_the_rest() {
fn empty_file_defaults_everything() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.registry_url, "wss://r.example/daemon");
assert_eq!(cfg.daemon_url, "ws://localhost:7070");
assert_eq!(cfg.token, "abc");
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(cfg.state_dir.ends_with("maestro"));
assert!(cfg.worktree_root.ends_with("worktrees"));
assert_eq!(cfg.daemon_id, cfg.hostname);
assert!(!cfg.hostname.is_empty());
}
#[test]
fn missing_file_with_full_env_works() {
// systemd / container scenario: no config file, everything in env.
fn missing_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
let env = EnvOverrides {
registry_url: Some("http://r.example".into()),
daemon_url: Some("ws://x:7070".into()),
token: Some("t".into()),
..EnvOverrides::default()
};
let cfg = load_config(&path, env).unwrap();
assert_eq!(cfg.registry_url, "ws://r.example/daemon");
assert_eq!(cfg.token, "t");
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(!path.exists(), "load_config must not create the file");
}
#[test]
fn missing_file_without_full_env_errors_with_hard_cutover_message() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
let err = load_config(&path, EnvOverrides::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("maestrod configure"), "msg: {msg}");
assert!(msg.contains("Legacy"), "msg: {msg}");
}
#[test]
fn env_overrides_file() {
let dir = tempfile::tempdir().unwrap();
@@ -368,68 +208,24 @@ daemon_url = "ws://localhost:7070"
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://from-file"
token = "from-file"
[daemon]
daemon_id = "from-file"
daemon_url = "ws://from-file:1"
bind_addr = "127.0.0.1:1"
"#,
)
.unwrap();
let env = EnvOverrides {
daemon_id: Some("from-env".into()),
registry_url: Some("https://from-env".into()),
bind_addr: Some("0.0.0.0:9999".into()),
daemon_url: Some("ws://from-env:1".into()),
token: Some("from-env".into()),
..EnvOverrides::default()
};
let cfg = load_config(&path, env).unwrap();
assert_eq!(cfg.daemon_id, "from-env");
assert_eq!(cfg.registry_url, "wss://from-env/daemon");
assert_eq!(cfg.daemon_url, "ws://from-env:1");
assert_eq!(cfg.token, "from-env");
assert_eq!(cfg.bind_addr, "0.0.0.0:9999".parse().unwrap());
}
#[test]
fn missing_required_field_reports_path_and_env_name() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
[daemon]
daemon_url = "ws://x"
"#,
)
.unwrap();
let err = load_config(&path, EnvOverrides::default()).unwrap_err();
match err {
ConfigError::Missing {
field,
env,
path: p,
} => {
assert_eq!(field, "shared.token");
assert_eq!(env, ENV_TOKEN);
assert_eq!(p, path);
}
other => panic!("expected Missing(shared.token), got {other:?}"),
}
}
#[test]
fn invalid_env_bind_addr_returns_diagnostic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
std::fs::write(&path, "").unwrap();
let env = EnvOverrides {
bind_addr: Some("not-an-addr".into()),
..EnvOverrides::default()
@@ -447,97 +243,27 @@ daemon_url = "ws://x"
}
#[test]
fn daemon_id_defaults_to_hostname() {
fn hostname_from_file_wins_over_gethostname() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
token = "t"
[daemon]
hostname = "vm-42"
daemon_url = "ws://x"
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.daemon_id, "vm-42");
assert_eq!(cfg.hostname, "vm-42");
}
#[test]
fn registry_url_must_be_http_or_https() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
registry_url = "ws://r/daemon"
token = "t"
[daemon]
daemon_url = "ws://x"
"#,
)
.unwrap();
let err = load_config(&path, EnvOverrides::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("registry_url"), "msg: {msg}");
}
#[test]
fn perimeter_fields_round_trip_through_file_and_env() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
token = "t"
[daemon]
daemon_url = "ws://x"
perimeter_id = "wg-naps62"
private_addr = "10.0.0.7:7070"
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.perimeter_id.as_deref(), Some("wg-naps62"));
assert_eq!(cfg.private_addr.as_deref(), Some("10.0.0.7:7070"));
// Env beats file.
let env = EnvOverrides {
perimeter_id: Some("lan-home".into()),
private_addr: Some("192.168.1.10:7070".into()),
..EnvOverrides::default()
};
let cfg = load_config(&path, env).unwrap();
assert_eq!(cfg.perimeter_id.as_deref(), Some("lan-home"));
assert_eq!(cfg.private_addr.as_deref(), Some("192.168.1.10:7070"));
}
#[test]
fn perimeter_fields_default_to_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert!(cfg.perimeter_id.is_none());
assert!(cfg.private_addr.is_none());
}
#[test]
fn spawn_history_days_defaults_and_overrides() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.spawn_history_days, DEFAULT_SPAWN_HISTORY_DAYS);
assert!(cfg.spawn_history_path().ends_with("spawn-history.jsonl"));
@@ -545,12 +271,7 @@ private_addr = "10.0.0.7:7070"
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
token = "t"
[daemon]
daemon_url = "ws://x"
spawn_history_days = 0
"#,
)
@@ -576,12 +297,7 @@ spawn_history_days = 0
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
token = "t"
[daemon]
daemon_url = "ws://x"
tmux_tmpdir = "/from-file"
"#,
)
@@ -601,12 +317,7 @@ tmux_tmpdir = "/from-file"
std::fs::write(
&path,
r#"
[shared]
registry_url = "http://r"
token = "t"
[daemon]
daemon_url = "ws://x"
tmux_tmpdir = "/from-file"
"#,
)
@@ -619,7 +330,7 @@ tmux_tmpdir = "/from-file"
fn tmux_tmpdir_defaults_to_xdg_runtime_dir_when_unset() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
// dirs::runtime_dir() is `Some(_)` on any Linux host with
// $XDG_RUNTIME_DIR set (CI included). When it's `None`, the daemon
-300
View File
@@ -1,300 +0,0 @@
//! `maestrod configure` — interactive + flag-driven config bootstrap.
//!
//! Writes the unified Maestro config file at `~/.config/maestro/config.toml`
//! (or `$MAESTRO_CONFIG`) from a mix of CLI flags and (when stdin is a TTY)
//! prompts. Refuses to overwrite an existing file unless `--force` is passed.
//!
//! With `--force`, the daemon-relevant sections (`[shared]` and `[daemon]`)
//! are rewritten, but any `[cli]` / `[registry]` sections already in the file
//! are preserved.
use std::io::{self, BufRead, IsTerminal, Write};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use maestro_config::{DaemonSection, MaestroConfigFile, SharedSection};
use crate::config::{system_hostname, ConfigError, DEFAULT_BIND_ADDR};
#[derive(Debug, thiserror::Error)]
pub enum ConfigureError {
#[error("config: {0}")]
Config(#[from] ConfigError),
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("invalid bind_addr {input:?}: {source}")]
InvalidBindAddr {
input: String,
source: std::net::AddrParseError,
},
#[error("{path:?} already exists — pass --force to overwrite")]
AlreadyExists { path: PathBuf },
#[error(
"missing required field(s) in non-interactive mode: {missing} \
(pass via flags or run interactively)"
)]
MissingRequired { missing: String },
}
/// User-supplied inputs from flags. `None` means "not given on the CLI";
/// the configurator will prompt or default depending on context.
#[derive(Debug, Default, Clone)]
pub struct ConfigureInputs {
pub registry_url: Option<String>,
pub daemon_url: Option<String>,
pub token: Option<String>,
pub daemon_id: Option<String>,
pub bind_addr: Option<String>,
pub state_dir: Option<PathBuf>,
pub worktree_root: Option<PathBuf>,
pub hostname: Option<String>,
}
/// Run `maestrod configure`. Writes the file at `path` (creating parents).
///
/// `interactive` controls prompt behaviour:
/// - `true` → prompt for any required field that's missing from `inputs`.
/// - `false` → error out instead (used in scripts / containers).
///
/// Returns the path that was written.
pub fn run(
path: &Path,
inputs: ConfigureInputs,
interactive: bool,
force: bool,
) -> Result<PathBuf, ConfigureError> {
if path.exists() && !force {
return Err(ConfigureError::AlreadyExists {
path: path.to_path_buf(),
});
}
let stdin = io::stdin();
let stdout = io::stdout();
let mut handle = stdout.lock();
let interactive_ok = interactive && stdin.is_terminal();
let registry_url = resolve_required(
"registry_url",
"Registry base URL (e.g. https://maestro.example.com)",
inputs.registry_url,
&mut handle,
&stdin,
interactive_ok,
)?;
let daemon_url = resolve_required(
"daemon_url",
"Reachable URL for this daemon (e.g. ws://laptop.tail-abc.ts.net:7070)",
inputs.daemon_url,
&mut handle,
&stdin,
interactive_ok,
)?;
let token = resolve_required(
"token",
"Shared token (MAESTRO_TOKEN; paste from the registry)",
inputs.token,
&mut handle,
&stdin,
interactive_ok,
)?;
let host = inputs.hostname.unwrap_or_else(system_hostname);
let daemon_id = inputs.daemon_id.unwrap_or_else(|| host.clone());
let bind_addr = match inputs.bind_addr.as_deref() {
Some(raw) => Some(parse_bind_addr(raw)?),
None => None,
};
// Read-modify-write: preserve `[cli]` / `[registry]` sections that may
// already exist (e.g. on a host that runs both the registry and the
// daemon, or one that's been used as a CLI client).
let mut file = MaestroConfigFile::load_or_default(path).map_err(ConfigError::from)?;
file.shared = SharedSection {
registry_url: Some(registry_url),
token: Some(token),
};
file.daemon = DaemonSection {
daemon_id: Some(daemon_id),
hostname: Some(host),
daemon_url: Some(daemon_url),
bind_addr,
state_dir: inputs.state_dir,
worktree_root: inputs.worktree_root,
// `maestrod configure` doesn't prompt for perimeter fields yet — those
// are operator-edited in config.toml directly. Leave None on bootstrap.
perimeter_id: None,
private_addr: None,
// None ⇒ daemon falls back to pty::DEFAULT_REPLAY_BYTES. Override in
// config.toml as `[daemon] pty_replay_bytes = ...` (NAP-229).
pty_replay_bytes: None,
// None ⇒ daemon resolves to $XDG_RUNTIME_DIR at startup. Override in
// config.toml as `[daemon] tmux_tmpdir = "/tmp"` (NAP-234).
tmux_tmpdir: None,
// None ⇒ daemon defaults to DEFAULT_SPAWN_HISTORY_DAYS (30). Override
// in config.toml as `[daemon] spawn_history_days = 0` to disable (#24).
spawn_history_days: None,
};
file.save(path).map_err(ConfigError::from)?;
let _ = handle; // stdout handle only used during prompts; binary prints "wrote ..."
Ok(path.to_path_buf())
}
fn resolve_required(
field: &'static str,
prompt: &str,
provided: Option<String>,
out: &mut impl Write,
stdin: &io::Stdin,
interactive_ok: bool,
) -> Result<String, ConfigureError> {
if let Some(v) = provided.filter(|v| !v.is_empty()) {
return Ok(v);
}
if !interactive_ok {
return Err(ConfigureError::MissingRequired {
missing: field.to_string(),
});
}
write!(out, "{prompt}: ")?;
out.flush()?;
let mut line = String::new();
stdin.lock().read_line(&mut line)?;
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
return Err(ConfigureError::MissingRequired {
missing: field.to_string(),
});
}
Ok(trimmed)
}
fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigureError> {
raw.parse()
.map_err(|source| ConfigureError::InvalidBindAddr {
input: raw.to_string(),
source,
})
}
/// Suggest a sensible default bind address. Currently `DEFAULT_BIND_ADDR`
/// from `config.rs`; here so callers don't import both modules.
pub fn default_bind_addr_hint() -> &'static str {
DEFAULT_BIND_ADDR
}
#[cfg(test)]
mod tests {
use super::*;
fn full_inputs() -> ConfigureInputs {
ConfigureInputs {
registry_url: Some("https://reg.example".into()),
daemon_url: Some("ws://localhost:7070".into()),
token: Some("secret".into()),
daemon_id: Some("laptop".into()),
bind_addr: Some("0.0.0.0:7070".into()),
state_dir: None,
worktree_root: None,
hostname: Some("laptop.lan".into()),
}
}
#[test]
fn non_interactive_with_full_flags_writes_unified_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
run(&path, full_inputs(), false, false).unwrap();
let written = MaestroConfigFile::load(&path).unwrap();
assert_eq!(
written.shared.registry_url.as_deref(),
Some("https://reg.example")
);
assert_eq!(written.shared.token.as_deref(), Some("secret"));
assert_eq!(written.daemon.daemon_id.as_deref(), Some("laptop"));
assert_eq!(
written.daemon.bind_addr.unwrap().to_string(),
"0.0.0.0:7070"
);
}
#[test]
fn non_interactive_missing_required_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut inputs = full_inputs();
inputs.token = None;
let err = run(&path, inputs, false, false).unwrap_err();
match err {
ConfigureError::MissingRequired { missing } => assert_eq!(missing, "token"),
other => panic!("expected MissingRequired(token), got {other:?}"),
}
assert!(!path.exists());
}
#[test]
fn refuses_to_overwrite_without_force() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "existing").unwrap();
let err = run(&path, full_inputs(), false, false).unwrap_err();
assert!(matches!(err, ConfigureError::AlreadyExists { .. }));
// file untouched
assert_eq!(std::fs::read_to_string(&path).unwrap(), "existing");
}
#[test]
fn force_preserves_unrelated_sections() {
// Existing file has [cli] (set by the user) and [registry] (set by
// a co-located registry on the same host). `configure --force`
// should overwrite [shared] + [daemon] but leave those alone.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[cli]
cookie = "keep-me"
[registry]
bind_addr = "0.0.0.0:8080"
"#,
)
.unwrap();
run(&path, full_inputs(), false, true).unwrap();
let written = MaestroConfigFile::load(&path).unwrap();
assert_eq!(written.shared.token.as_deref(), Some("secret"));
assert_eq!(written.cli.cookie.as_deref(), Some("keep-me"));
assert_eq!(
written.registry.bind_addr,
Some("0.0.0.0:8080".parse().unwrap())
);
}
#[test]
fn invalid_bind_addr_flag_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut inputs = full_inputs();
inputs.bind_addr = Some("nope".into());
let err = run(&path, inputs, false, false).unwrap_err();
assert!(matches!(err, ConfigureError::InvalidBindAddr { .. }));
}
#[test]
fn daemon_id_defaults_to_hostname_when_neither_given() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let inputs = ConfigureInputs {
registry_url: Some("http://r".into()),
daemon_url: Some("ws://x".into()),
token: Some("t".into()),
hostname: Some("override-host".into()),
..ConfigureInputs::default()
};
run(&path, inputs, false, false).unwrap();
let written = MaestroConfigFile::load(&path).unwrap();
assert_eq!(written.daemon.daemon_id.as_deref(), Some("override-host"));
assert_eq!(written.daemon.hostname.as_deref(), Some("override-host"));
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
//! - relative `a/b` → home + `a/b`
//! - absolute `/a/b` → as-is
//!
//! The pure [`list_dir`] is unit-tested in isolation; the registry-client task
//! The pure [`list_dir`] is unit-tested in isolation; the HTTP handler
//! just wraps its `Result` in an `RpcResult` for the wire.
use std::path::{Path, PathBuf};
+1 -1
View File
@@ -12,7 +12,7 @@
//! to its worktree toplevel.
//!
//! It's a pure filesystem walk with no `git` subprocess — same shape as
//! [`crate::dirlist`]: unit-tested in isolation, and the registry-client task /
//! [`crate::dirlist`]: unit-tested in isolation, and the HTTP handler /
//! HTTP layer (#26) just wraps its `Option` for the wire.
use std::path::{Path, PathBuf};
-265
View File
@@ -1,265 +0,0 @@
//! HS256 JWT validation. Tokens are minted by the registry (NAP-215) and
//! validated locally by each daemon using the shared secret — no callback to
//! the registry on the attach hot path. Spec §Auth.
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use maestro_protocol::{JwtClaims, PairAttachClaims, REGISTRY_ISSUER};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum JwtError {
#[error("token decode failed: {0}")]
Decode(#[from] jsonwebtoken::errors::Error),
#[error("daemon_id mismatch (token wants `{wanted}`, daemon is `{ours}`)")]
WrongDaemon { wanted: String, ours: String },
#[error("session mismatch (token wants `{wanted}`, path is `{path}`)")]
WrongSession { wanted: Uuid, path: Uuid },
}
#[derive(Clone)]
pub struct JwtVerifier {
decoding: DecodingKey,
daemon_id: String,
}
impl std::fmt::Debug for JwtVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwtVerifier")
.field("daemon_id", &self.daemon_id)
.field("decoding", &"<redacted>")
.finish()
}
}
impl JwtVerifier {
pub fn new(secret: &str, daemon_id: impl Into<String>) -> Self {
Self {
decoding: DecodingKey::from_secret(secret.as_bytes()),
daemon_id: daemon_id.into(),
}
}
/// Decodes and validates the token. Returns the claims on success.
/// `exp` is enforced by `jsonwebtoken` with default 60s leeway.
pub fn verify(&self, token: &str) -> Result<JwtClaims, JwtError> {
let validation = Validation::new(Algorithm::HS256);
let data = decode::<JwtClaims>(token, &self.decoding, &validation)?;
if data.claims.daemon_id != self.daemon_id {
return Err(JwtError::WrongDaemon {
wanted: data.claims.daemon_id,
ours: self.daemon_id.clone(),
});
}
Ok(data.claims)
}
}
/// Mint a token. The daemon doesn't issue JWTs in production — this is here
/// for tests and for the future registry crate's convenience.
pub fn issue(secret: &str, claims: &JwtClaims) -> Result<String, jsonwebtoken::errors::Error> {
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, claims, &key)
}
/// Verifier for pair-scoped attach JWTs (NAP-247). Checks signature, `exp`,
/// `iss == REGISTRY_ISSUER`, `aud == self.daemon_id`, and `sid == session_id`
/// — i.e. the token was minted by the registry for *this* daemon and *this*
/// session, and hasn't expired. This is the session-binding guarantee the
/// network perimeter can't provide: a host the firewall admits still can't
/// attach to a session it wasn't handed, or replay a token to another
/// session/daemon.
///
/// It does **not** gate on the home daemon's identity (no `allow_inbound`
/// allow-list, NAP-339): the operator's perimeter/firewall is the trust
/// boundary for *which hosts may connect*, so every daemon on the perimeter is
/// equally trusted to attach. Re-introducing a per-pair gate later is a
/// verify-time check, not an architecture change.
#[derive(Clone)]
pub struct PairAttachVerifier {
decoding: DecodingKey,
daemon_id: String,
}
impl std::fmt::Debug for PairAttachVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PairAttachVerifier")
.field("daemon_id", &self.daemon_id)
.field("decoding", &"<redacted>")
.finish()
}
}
impl PairAttachVerifier {
pub fn new(secret: &str, daemon_id: impl Into<String>) -> Self {
Self {
decoding: DecodingKey::from_secret(secret.as_bytes()),
daemon_id: daemon_id.into(),
}
}
/// Validate the token for the path-bound `session_id`. On success returns
/// the home daemon's id (`sub`) for logging/tracing.
pub fn verify(&self, token: &str, session_id: Uuid) -> Result<String, JwtError> {
let mut validation = Validation::new(Algorithm::HS256);
// Require explicit audience match — the jsonwebtoken crate validates
// `aud` against this set on decode.
validation.set_audience(&[self.daemon_id.as_str()]);
// Bind to the registry issuer so a differently-purposed token signed
// with the same shared secret (e.g. an auth cookie) can't validate as a
// pair-attach credential (NAP-266).
validation.set_issuer(&[REGISTRY_ISSUER]);
let data = decode::<PairAttachClaims>(token, &self.decoding, &validation)?;
let claims = data.claims;
if claims.aud != self.daemon_id {
return Err(JwtError::WrongDaemon {
wanted: claims.aud,
ours: self.daemon_id.clone(),
});
}
if claims.sid != session_id {
return Err(JwtError::WrongSession {
wanted: claims.sid,
path: session_id,
});
}
Ok(claims.sub)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
use uuid::Uuid;
fn claims_for(daemon_id: &str, valid_seconds: i64) -> JwtClaims {
JwtClaims {
session_id: Uuid::new_v4(),
daemon_id: daemon_id.into(),
exp: (Utc::now() + Duration::seconds(valid_seconds)).timestamp(),
iat: Some(Utc::now().timestamp()),
}
}
#[test]
fn good_token_round_trip() {
let secret = "super-secret";
let v = JwtVerifier::new(secret, "laptop");
let c = claims_for("laptop", 300);
let token = issue(secret, &c).unwrap();
let back = v.verify(&token).unwrap();
assert_eq!(back.daemon_id, "laptop");
assert_eq!(back.session_id, c.session_id);
}
#[test]
fn rejects_wrong_daemon() {
let secret = "s";
let v = JwtVerifier::new(secret, "laptop");
let c = claims_for("dev-vm", 300);
let token = issue(secret, &c).unwrap();
let err = v.verify(&token).unwrap_err();
assert!(matches!(err, JwtError::WrongDaemon { .. }));
}
#[test]
fn rejects_expired() {
let secret = "s";
let v = JwtVerifier::new(secret, "laptop");
let c = claims_for("laptop", -120); // expired 2 minutes ago, beyond default 60s leeway
let token = issue(secret, &c).unwrap();
assert!(v.verify(&token).is_err());
}
#[test]
fn rejects_wrong_secret() {
let v = JwtVerifier::new("right", "laptop");
let token = issue("wrong", &claims_for("laptop", 300)).unwrap();
assert!(v.verify(&token).is_err());
}
// ----- PairAttachVerifier -----
fn pair_claims(home: &str, target: &str, sid: Uuid, valid_seconds: i64) -> PairAttachClaims {
PairAttachClaims {
iss: REGISTRY_ISSUER.into(),
sub: home.into(),
aud: target.into(),
sid,
exp: (Utc::now() + Duration::seconds(valid_seconds)).timestamp(),
}
}
fn issue_pair(secret: &str, claims: &PairAttachClaims) -> String {
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, claims, &key).unwrap()
}
#[test]
fn pair_verifier_accepts_registry_minted_token() {
let secret = "s";
let v = PairAttachVerifier::new(secret, "konishi");
let sid = Uuid::new_v4();
// The home daemon's identity is no longer gated (NAP-339) — any
// registry-minted, correctly-bound token for this daemon+session passes.
let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60));
let sub = v.verify(&token, sid).unwrap();
assert_eq!(sub, "laptop");
}
#[test]
fn pair_verifier_rejects_wrong_audience() {
let secret = "s";
let v = PairAttachVerifier::new(secret, "konishi");
let sid = Uuid::new_v4();
// Token addressed to a different target — must not pass our checks.
let token = issue_pair(secret, &pair_claims("laptop", "elsewhere", sid, 60));
assert!(v.verify(&token, sid).is_err());
}
#[test]
fn pair_verifier_rejects_foreign_issuer() {
// NAP-266: a token signed with the right secret but a non-registry
// `iss` (e.g. minted for some other purpose) must not validate as a
// pair-attach credential.
let secret = "s";
let v = PairAttachVerifier::new(secret, "konishi");
let sid = Uuid::new_v4();
let mut claims = pair_claims("laptop", "konishi", sid, 60);
claims.iss = "somebody-else".into();
let token = issue_pair(secret, &claims);
assert!(v.verify(&token, sid).is_err());
}
#[test]
fn pair_verifier_rejects_session_id_mismatch() {
let secret = "s";
let v = PairAttachVerifier::new(secret, "konishi");
let sid = Uuid::new_v4();
let path = Uuid::new_v4();
let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60));
assert!(matches!(
v.verify(&token, path).unwrap_err(),
JwtError::WrongSession { .. }
));
}
#[test]
fn pair_verifier_rejects_expired() {
let secret = "s";
let v = PairAttachVerifier::new(secret, "konishi");
let sid = Uuid::new_v4();
let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, -120));
assert!(v.verify(&token, sid).is_err());
}
#[test]
fn pair_verifier_rejects_wrong_secret() {
let v = PairAttachVerifier::new("right", "konishi");
let sid = Uuid::new_v4();
let token = issue_pair("wrong", &pair_claims("laptop", "konishi", sid, 60));
assert!(v.verify(&token, sid).is_err());
}
}
+7 -17
View File
@@ -1,37 +1,27 @@
//! `maestrod` — the per-host Maestro daemon.
//! `maestro daemon` — the per-host Maestro daemon.
//!
//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §Daemon for the
//! component's responsibilities. This library exposes the pieces; the binary
//! entry point in `src/main.rs` wires them together.
//! Wraps tmux, owns every session on the machine, and serves them to clients
//! (CLI, TUI, web) over a plain HTTP + WebSocket API on a local bind address.
//! No registry, no peers, no auth layer: reaching the bind address is the
//! authorization.
#![deny(missing_debug_implementations)]
pub mod activity;
pub mod attach;
pub mod broker;
pub mod catalog_mirror;
pub mod cli;
pub mod config;
pub mod configure;
pub mod dirlist;
pub mod gitroot;
pub mod jwt;
pub mod local_api;
pub mod local_events;
pub mod panes;
pub mod proc;
pub mod pty;
pub mod registry_client;
pub mod session;
pub mod sidecar;
pub mod spawn_history;
pub mod tmux;
pub mod worktree;
pub use broker::{BrokerError, BrokerHandle};
pub use catalog_mirror::CatalogMirror;
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
pub use configure::{run as run_configure, ConfigureError, ConfigureInputs};
pub use jwt::{issue as issue_jwt, JwtError, JwtVerifier, PairAttachVerifier};
pub use local_events::{spawn_session_publisher, LocalEventBus};
pub use registry_client::{ClientError, RegistryClient, HEARTBEAT_INTERVAL};
pub use session::{SessionError, SessionEvent, SessionManager};
pub use session::{SessionError, SessionManager};
+89 -422
View File
@@ -1,27 +1,14 @@
//! Daemon-side HTTP API for local clients (NAP-243).
//! Daemon-side HTTP API for clients (NAP-243).
//!
//! These routes live on the daemon's existing `bind_addr` (default
//! `127.0.0.1:7070`) alongside the legacy `/sessions/:id/stream` WS endpoint.
//! They're the contract `maestro-web` (NAP-241) and the rewired CLI (NAP-244)
//! call against — a same-origin, same-trust surface that doesn't need a
//! registry round-trip for catalog reads.
//! These routes live on the daemon's `bind_addr` (default `127.0.0.1:7070`).
//! They're the contract `maestro-web`, the TUI, and the CLI call against.
//!
//! ## Trust model (v1)
//! ## Trust model
//!
//! Whoever can reach `bind_addr` can use these routes. No JWT, no cookie. The
//! default `127.0.0.1` bind makes this same-trust as tmux (one-user-wide).
//! Operators who bind a perimeter address (LAN/WG IP) opt into one-segment-wide
//! trust — same model `maestro-web` documents per the connection-orchestration
//! spec. Cookie auth can be added later if a real use case demands it.
//!
//! ## Scope
//!
//! The daemon surfaces its own sessions plus the peer-catalog mirror (NAP-246).
//! Cross-daemon operations are brokered over the registry control link:
//! `WS /api/attach/:id` dials a peer over T2 (NAP-247), and `POST /api/sessions`
//! / `DELETE /api/sessions/:id` for a peer-owned target relay a create/kill RPC
//! to the owning daemon (NAP-327) — control-plane only, no session bytes
//! traverse the registry.
//! Operators who bind a LAN/VPN address opt into one-segment-wide trust — the
//! perimeter is theirs to choose, Maestro does not police it.
use std::sync::Arc;
@@ -32,112 +19,57 @@ use axum::routing::{any, delete, get};
use axum::{Json, Router};
use maestro_config::SpawnPreset;
use maestro_protocol::{
CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session,
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, Session,
PROTOCOL_VERSION,
};
use serde::{Deserialize, Serialize};
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 and its spawn presets (#26).
#[derive(Debug, Clone, Serialize)]
pub struct WebInfo {
pub daemon_id: String,
pub hostname: String,
pub version: String,
pub daemon_url: String,
/// Spawn-form command presets resolved from `[spawn].presets` (#22), with
/// the implicit `custom` tile appended. The web renders these as one-click
/// tiles; it rides this existing payload so opening the form needs no extra
/// round-trip (#26).
pub presets: Vec<SpawnPreset>,
}
#[derive(Debug, Clone)]
pub struct LocalApiState {
pub manager: SessionManager,
pub hostname: Arc<String>,
pub daemon_url: Arc<String>,
/// Home directory of the OS user this daemon runs as, advertised so clients
/// can prefill a spawn form's working directory. Empty if unresolved.
pub home_dir: Arc<String>,
/// Resolved spawn presets (#22/#26) advertised in `GET /api/web/info`.
/// Resolved spawn presets (#22/#26) advertised in `GET /api/info`.
pub presets: Arc<Vec<SpawnPreset>>,
/// Peer-only mirror of the global catalog. Empty until the daemon's
/// registry client connects and receives the snapshot.
pub catalog: CatalogMirror,
/// Daemon-local fan-out of own + peer catalog deltas (NAP-248). The
/// `/api/events` WS subscribes here; everything else just reads REST.
pub events: LocalEventBus,
/// Control link to the registry, used to broker cross-daemon create/kill
/// (NAP-327). When the link is down, brokered ops fail fast.
pub broker: BrokerHandle,
}
pub fn router(state: LocalApiState) -> Router {
Router::new()
.route("/api/sessions", get(list_sessions).post(create_session))
.route("/api/sessions/:id", delete(kill_session))
.route("/api/daemons", get(list_daemons))
.route("/api/daemons/:id/dirs", get(list_dir))
.route("/api/daemons/:id/recent-dirs", get(recent_dirs))
.route("/api/daemons/:id/gitroot", get(git_root))
.route("/api/web/info", get(web_info))
.route("/api/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::GITROOT, get(git_root))
.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(),
home_dir: (*s.home_dir).clone(),
};
let mut out = vec![self_info];
out.extend(s.catalog.daemons().await);
Json(out)
}
/// Query string for `GET /api/daemons/:id/dirs`. `path` is the directory to
/// list (the parent of what the user is typing); empty/`~`/relative are
/// resolved against the target daemon's home dir.
/// Query string for `GET /api/dirs`. `path` is the directory to list (the
/// parent of what the user is typing); empty/`~`/relative are resolved against
/// the daemon's home dir.
#[derive(Debug, Deserialize)]
struct DirQuery {
#[serde(default)]
path: String,
}
/// List one directory's immediate subdirectories on `daemon_id`'s host, for the
/// spawn form's working-directory autocomplete (NAP-329). Lists locally when
/// the target is this daemon; otherwise brokers a `ListDir` to the owning
/// daemon over the registry control link.
async fn list_dir(
State(s): State<LocalApiState>,
Path(daemon_id): Path<String>,
Query(q): Query<DirQuery>,
) -> Response {
if daemon_id == s.manager.daemon_id() {
return match crate::dirlist::list_dir(&s.home_dir, &q.path) {
Ok(listing) => Json(listing).into_response(),
Err(info) => error_response(dir_error_status(&info.code), &info.code, &info.message),
};
}
match s.broker.forward_list_dir(daemon_id, q.path).await {
Ok(listing) => Json::<DirListing>(listing).into_response(),
Err(e) => broker_error_response(&e),
/// List one directory's immediate subdirectories, for the spawn form's
/// working-directory autocomplete (NAP-329).
async fn list_dir(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) -> Response {
match crate::dirlist::list_dir(&s.home_dir, &q.path) {
Ok(listing) => Json(listing).into_response(),
Err(info) => error_response(dir_error_status(&info.code), &info.code, &info.message),
}
}
@@ -151,48 +83,37 @@ fn dir_error_status(code: &str) -> StatusCode {
}
}
/// Recent working dirs on `daemon_id`'s host for the spawn form's recents
/// picker (#26). Reads locally when the target is this daemon; otherwise brokers
/// a `RecentDirs` query to the owning daemon over the registry control link.
async fn recent_dirs(State(s): State<LocalApiState>, Path(daemon_id): Path<String>) -> Response {
if daemon_id == s.manager.daemon_id() {
let value = crate::spawn_history::to_wire(s.manager.recent_dirs());
return Json(value).into_response();
}
match s.broker.forward_recent_dirs(daemon_id).await {
Ok(recents) => Json::<RecentDirs>(recents).into_response(),
Err(e) => broker_error_response(&e),
/// 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,
_ => StatusCode::BAD_GATEWAY,
}
}
/// Probe the enclosing git repo of `path` on `daemon_id`'s host, to infer the
/// worktree `base_repo` for the spawn form (#26). Local when the target is this
/// daemon; otherwise brokered to the owning daemon. `path` is resolved against
/// the target daemon's home dir (empty/`~`/relative), like [`list_dir`].
async fn git_root(
State(s): State<LocalApiState>,
Path(daemon_id): Path<String>,
Query(q): Query<DirQuery>,
) -> Response {
if daemon_id == s.manager.daemon_id() {
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
let value = GitRoot {
root: crate::gitroot::find_root(&dir),
};
return Json(value).into_response();
}
match s.broker.forward_git_root(daemon_id, q.path).await {
Ok(root) => Json::<GitRoot>(root).into_response(),
Err(e) => broker_error_response(&e),
}
/// 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()
}
async fn web_info(State(s): State<LocalApiState>) -> Json<WebInfo> {
Json(WebInfo {
daemon_id: s.manager.daemon_id().to_string(),
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
/// (empty/`~`/relative), like [`list_dir`].
async fn git_root(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) -> Response {
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
Json(GitRoot {
root: crate::gitroot::find_root(&dir),
})
.into_response()
}
async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
Json(DaemonInfo {
hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(),
home_dir: (*s.home_dir).clone(),
presets: (*s.presets).clone(),
})
}
@@ -201,20 +122,12 @@ async fn create_session(
State(s): State<LocalApiState>,
Json(body): Json<CreateSessionRequest>,
) -> Response {
if body.daemon_id != s.manager.daemon_id() {
// Cross-daemon create: broker it to the owning daemon over the registry
// control link (NAP-327). Control-plane only — the owner does the spawn.
return match s.broker.forward_create(body).await {
Ok(session) => (StatusCode::CREATED, Json(session)).into_response(),
Err(e) => broker_error_response(&e),
};
}
match s.manager.create(body).await {
Ok(session) => (StatusCode::CREATED, Json(session)).into_response(),
Err(e) => {
let info = e.to_error_info();
error_response(
StatusCode::BAD_GATEWAY,
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
)
@@ -222,36 +135,16 @@ async fn create_session(
}
}
/// `kill` already reports a missing session as `SessionError::NotFound`, which
/// maps to 404. Checking first would only add a window in which the liveness
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
if !s.manager.has(id).await {
// Not ours — if a peer owns it, broker the kill to that daemon over the
// registry control link (NAP-327). Only a session no daemon knows about
// is a real 404.
let owner = s
.catalog
.sessions()
.await
.into_iter()
.find(|sess| sess.id == id)
.map(|sess| sess.daemon_id);
return match owner {
Some(daemon_id) => match s.broker.forward_kill(daemon_id, id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => broker_error_response(&e),
},
None => error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon or any known peer",
),
};
}
match s.manager.kill(id).await {
Ok(()) => StatusCode::NO_CONTENT.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(),
)
@@ -259,69 +152,55 @@ async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> R
}
}
/// Map a [`BrokerError`] from a forwarded create/kill (NAP-327) to a structured
/// HTTP error. Mirrors `broker_error_code`'s code points but also picks the
/// status the CLI/web client should react to.
fn broker_error_response(e: &BrokerError) -> Response {
let (code, message) = broker_error_code(e);
let status = match e {
// Registry link down or back-pressured: the home daemon can't broker
// right now — a retryable 503.
BrokerError::NoControlLink => StatusCode::SERVICE_UNAVAILABLE,
BrokerError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
BrokerError::ReplyClosed => StatusCode::BAD_GATEWAY,
// Registry- or owner-reported faults. Distinguish "nothing owns this"
// (404) from spawn/transport failures (502).
BrokerError::Registry(c, _) => match c.as_str() {
"unknown_session" => StatusCode::NOT_FOUND,
"daemon_not_connected" | "daemon_gone" => StatusCode::BAD_GATEWAY,
"rpc_timeout" => StatusCode::GATEWAY_TIMEOUT,
_ => StatusCode::BAD_GATEWAY,
},
};
error_response(status, code, &message)
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(
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.
#[allow(clippy::too_many_arguments)]
pub fn state_for(
manager: SessionManager,
hostname: impl Into<String>,
daemon_url: impl Into<String>,
home_dir: impl Into<String>,
presets: Vec<SpawnPreset>,
catalog: CatalogMirror,
events: LocalEventBus,
broker: BrokerHandle,
) -> LocalApiState {
LocalApiState {
manager,
hostname: Arc::new(hostname.into()),
daemon_url: Arc::new(daemon_url.into()),
home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets),
catalog,
events,
broker,
}
}
// ---------------------------------------------------------------------------
// WS /api/events — live catalog deltas for browser/CLI subscribers (NAP-248)
// WS /api/attach/:id — raw PTY bytes for one session
// ---------------------------------------------------------------------------
#[allow(clippy::result_large_err)]
/// 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(
@@ -339,7 +218,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();
@@ -347,20 +225,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(),
home_dir: (*s.home_dir).clone(),
let snapshot = CatalogEvent::Snapshot {
sessions: s.manager.snapshot().await,
};
let mut daemons = vec![self_info];
daemons.extend(s.catalog.daemons().await);
let mut sessions = s.manager.snapshot().await;
sessions.extend(s.catalog.sessions().await);
let snapshot = RegistryEvent::Snapshot { daemons, sessions };
let text = serde_json::to_string(&snapshot).expect("snapshot encode");
if sink.send(Message::Text(text)).await.is_err() {
return Ok(());
@@ -371,7 +240,7 @@ async fn pump_events(
ev = rx.recv() => {
let event = match ev {
Ok(e) => e,
Err(RecvError::Lagged(n)) => RegistryEvent::Lagged { skipped: n },
Err(RecvError::Lagged(n)) => CatalogEvent::Lagged { skipped: n },
Err(RecvError::Closed) => break,
};
let text = serde_json::to_string(&event).expect("event encode");
@@ -393,205 +262,3 @@ async fn pump_events(
}
Ok(())
}
// ---------------------------------------------------------------------------
// Broker WS — `WS /api/attach/:session_id`
// ---------------------------------------------------------------------------
//
// Home-daemon broker (NAP-243 + NAP-247):
//
// 1. Session owned by self → delegate to local PTY subscribe.
// 2. Session owned by a peer daemon → ResolveAttach via the registry, dial
// the peer over T2, proxy frames.
// 3. Unknown session → 404.
use crate::broker::{BrokerError, BrokerHandle};
#[derive(Debug, Clone)]
pub struct BrokerState {
pub manager: SessionManager,
pub catalog: CatalogMirror,
pub broker: BrokerHandle,
pub self_daemon_id: Arc<String>,
pub self_perimeter_id: Option<Arc<String>>,
}
pub fn broker_router(state: BrokerState) -> Router {
Router::new()
.route("/api/attach/:id", any(broker_attach_ws))
.with_state(state)
}
async fn broker_attach_ws(
Path(id): Path<Uuid>,
State(state): State<BrokerState>,
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
if state.manager.has(id).await {
// Local short-circuit.
let manager = state.manager.clone();
return Ok(ws.on_upgrade(move |socket| async move {
let _ = crate::attach::serve_local(socket, manager, id).await;
}));
}
// Look up which peer owns the session via the catalog mirror.
let peer = state
.catalog
.sessions()
.await
.into_iter()
.find(|s| s.id == id)
.map(|s| s.daemon_id);
let Some(peer_daemon_id) = peer else {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon or any known peer",
));
};
// ResolveAttach via the control link.
let candidates = match state.broker.resolve_attach(id).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(%id, %peer_daemon_id, error = %e, "ResolveAttach failed");
let (code, msg) = broker_error_code(&e);
return Err(error_response(StatusCode::BAD_GATEWAY, code, &msg));
}
};
// Pick a transport. T1 is canceled (NAP-235); T3 is gated (NAP-238).
// For T2 we need both daemons to declare the same perimeter id, and the
// candidate must include a private address.
let perimeter = candidates.perimeter.clone();
let our_perimeter = state.self_perimeter_id.as_deref().map(|s| s.as_str());
let dial_url = match (perimeter, our_perimeter) {
(Some(p), Some(ours)) if p.perimeter_id == ours => {
format!(
"ws://{}/sessions/{}/stream?token={}",
p.private_addr,
id,
urlencoding::encode(&candidates.jwt),
)
}
(Some(_), Some(_)) => {
return Err(error_response(
StatusCode::BAD_GATEWAY,
"perimeter_mismatch",
"peer is on a different perimeter; T2 unavailable, T1/T3 not implemented",
));
}
(Some(_), None) => {
return Err(error_response(
StatusCode::BAD_GATEWAY,
"no_local_perimeter",
"this daemon has no perimeter_id set; can't use T2",
));
}
(None, _) => {
return Err(error_response(
StatusCode::BAD_GATEWAY,
"no_perimeter_candidate",
"peer hasn't advertised a perimeter; T2 unavailable",
));
}
};
// Dial the peer BEFORE completing the upgrade. A failure here — peer down,
// perimeter-side rejection, or the peer's verifier refusing the pair JWT
// (all of which the peer reports pre-upgrade) — then surfaces as a
// structured `502` the client can act on, instead of an opaque
// post-upgrade close indistinguishable from a clean session end (NAP-265).
let upstream = match tokio_tungstenite::connect_async(&dial_url).await {
Ok((up, _)) => up,
Err(e) => {
tracing::warn!(%id, url = %dial_url, error = %e, "T2 dial to peer failed");
return Err(error_response(
StatusCode::BAD_GATEWAY,
"peer_unreachable",
&format!("could not open the data-plane connection to the session's daemon: {e}"),
));
}
};
Ok(ws.on_upgrade(move |socket| async move {
let _ = pump_broker_ws(socket, upstream).await;
}))
}
/// Bidirectional pump between the client WS and an already-connected peer WS.
async fn pump_broker_ws(
client: axum::extract::ws::WebSocket,
upstream: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> Result<(), axum::Error> {
use axum::extract::ws::Message as AxMessage;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as WsMessage;
let (mut client_sink, mut client_stream) = client.split();
let (mut up_sink, mut up_stream) = upstream.split();
let client_to_up = async {
while let Some(Ok(msg)) = client_stream.next().await {
let frame = match msg {
AxMessage::Text(t) => WsMessage::Text(t),
AxMessage::Binary(b) => WsMessage::Binary(b),
AxMessage::Ping(p) => WsMessage::Ping(p),
AxMessage::Pong(p) => WsMessage::Pong(p),
AxMessage::Close(_) => break,
};
if up_sink.send(frame).await.is_err() {
break;
}
}
};
let up_to_client = async {
while let Some(Ok(msg)) = up_stream.next().await {
let frame = match msg {
WsMessage::Text(t) => AxMessage::Text(t),
WsMessage::Binary(b) => AxMessage::Binary(b),
WsMessage::Ping(p) => AxMessage::Ping(p),
WsMessage::Pong(p) => AxMessage::Pong(p),
WsMessage::Close(_) | WsMessage::Frame(_) => break,
};
if client_sink.send(frame).await.is_err() {
break;
}
}
};
tokio::select! {
_ = client_to_up => {}
_ = up_to_client => {}
}
Ok(())
}
fn broker_error_code(e: &BrokerError) -> (&'static str, String) {
match e {
BrokerError::NoControlLink => (
"no_registry_link",
"this daemon is not connected to the registry".into(),
),
BrokerError::ReplyClosed => ("reply_closed", e.to_string()),
BrokerError::Timeout(_) => ("resolve_timeout", e.to_string()),
BrokerError::Registry(code, msg) => (
// Code points are stable across the protocol so the client can
// distinguish "unknown_session" from "self_target" etc.
match code.as_str() {
"unknown_session" => "unknown_session",
"self_target" => "self_target",
"jwt_failed" => "jwt_failed",
// Forwarded create/kill transport faults (NAP-327).
"daemon_not_connected" => "daemon_not_connected",
"daemon_gone" => "daemon_gone",
"rpc_timeout" => "rpc_timeout",
_ => "registry_error",
},
msg.clone(),
),
}
}
-126
View File
@@ -1,126 +0,0 @@
//! Daemon-local fan-out of `RegistryEvent`s for the `WS /api/events`
//! endpoint (NAP-248).
//!
//! Two sources feed this bus:
//!
//! 1. **Own-session events** — the daemon's `SessionManager::events()` stream,
//! translated from `SessionEvent` into the matching `RegistryEvent` variant
//! by [`spawn_session_publisher`].
//! 2. **Peer-catalog events** — applied to the daemon's [`CatalogMirror`] when
//! the registry fans them out (NAP-246). `CatalogMirror::apply` publishes
//! each event here after mutating its local view.
//!
//! Subscribers get the merged stream. The `WS /api/events` handler builds a
//! one-shot `Snapshot` of own ∪ peer state on connect, then forwards live
//! deltas until the client disconnects. `Lagged` follows the registry's
//! existing convention — clients resync via REST.
use std::sync::Arc;
use maestro_protocol::RegistryEvent;
use tokio::sync::broadcast;
use crate::session::{SessionEvent, SessionManager};
/// Capacity of the per-bus broadcast channel. Sourced from the shared
/// [`maestro_config::BroadcastTuning`], which the registry's
/// `EVENT_CHANNEL_CAPACITY` also tracks, so a slow client behaves the same on
/// either side (NAP-273).
const BUS_CAPACITY: usize = maestro_config::BroadcastTuning::DEFAULT.events_capacity;
/// Cheap-to-clone handle around a `broadcast::Sender<RegistryEvent>`.
#[derive(Debug, Clone)]
pub struct LocalEventBus {
sender: Arc<broadcast::Sender<RegistryEvent>>,
}
impl LocalEventBus {
pub fn new() -> Self {
let (sender, _) = broadcast::channel(BUS_CAPACITY);
Self {
sender: Arc::new(sender),
}
}
/// Push an event onto the bus. No-op if there are no subscribers.
pub fn publish(&self, event: RegistryEvent) {
let _ = self.sender.send(event);
}
pub fn subscribe(&self) -> broadcast::Receiver<RegistryEvent> {
self.sender.subscribe()
}
}
impl Default for LocalEventBus {
fn default() -> Self {
Self::new()
}
}
/// Spawn a task that converts each `SessionEvent` from `manager` into the
/// matching `RegistryEvent` and publishes it on `bus`. Exits when the
/// `SessionManager` is dropped (the events channel closes).
pub fn spawn_session_publisher(manager: SessionManager, bus: LocalEventBus) {
let daemon_id = manager.daemon_id().to_string();
tokio::spawn(async move {
let mut rx = manager.events();
loop {
match rx.recv().await {
Ok(SessionEvent::Added(session)) => {
bus.publish(RegistryEvent::SessionAdded { session });
}
Ok(SessionEvent::Updated(session)) => {
bus.publish(RegistryEvent::SessionUpdated { session });
}
Ok(SessionEvent::Removed(id)) => {
bus.publish(RegistryEvent::SessionRemoved {
daemon_id: daemon_id.clone(),
id,
});
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
bus.publish(RegistryEvent::Lagged { skipped });
}
Err(broadcast::error::RecvError::Closed) => return,
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn publish_visible_to_subscriber() {
let bus = LocalEventBus::new();
let mut rx = bus.subscribe();
bus.publish(RegistryEvent::Lagged { skipped: 7 });
match rx.recv().await {
Ok(RegistryEvent::Lagged { skipped }) => assert_eq!(skipped, 7),
other => panic!("unexpected: {other:?}"),
}
}
#[tokio::test]
async fn no_subscribers_swallows_event() {
let bus = LocalEventBus::new();
// No one's listening — `publish` must not panic and must not block.
bus.publish(RegistryEvent::DaemonRemoved {
daemon_id: "ghost".into(),
});
}
#[tokio::test]
async fn clones_share_state() {
let a = LocalEventBus::new();
let b = a.clone();
let mut rx = a.subscribe();
b.publish(RegistryEvent::Lagged { skipped: 3 });
match rx.recv().await {
Ok(RegistryEvent::Lagged { skipped }) => assert_eq!(skipped, 3),
other => panic!("unexpected: {other:?}"),
}
}
}
+290
View File
@@ -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);
}
}
+183 -49
View File
@@ -1,8 +1,8 @@
//! Best-effort `/proc` inspection for the activity watcher (NAP-340, Linux).
//!
//! Decides whether a session is **blocked waiting on the user**: walk from the
//! pane's process down to the deepest descendant (the agent/shell actually on
//! the terminal) and check it's parked waiting on terminal input.
//! 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
@@ -16,55 +16,120 @@
//! Every step degrades to `false` on a read failure, so a non-Linux host or a
//! denied `/proc` read falls back to `Idle`.
/// Whether the process tree under `pane_pid` looks blocked on terminal input
/// from `tty` (e.g. `/dev/pts/3`) — the signal for `AwaitingInput`.
pub fn awaiting_input(pane_pid: i32, tty: &str) -> bool {
let leaf = deepest_descendant(pane_pid);
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 read_state(leaf) != Some('S') {
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(leaf) {
if is_timer_sleep(pid) {
return false;
}
fd_points_at_tty(leaf, tty)
fd_points_at_tty(pid, tty)
}
/// Follow the (last) child link down the process tree to the deepest
/// descendant. Bounded so a pathological/cyclic tree can't loop.
fn deepest_descendant(pid: i32) -> i32 {
let mut cur = pid;
for _ in 0..64 {
match last_child(cur) {
Some(child) if child != cur => cur = child,
_ => break,
}
}
cur
/// 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,
}
/// The most-recently-created child of `pid`, via
/// `/proc/<pid>/task/<pid>/children` (a space-separated pid list). The last
/// entry is the newest child — typically the foreground one an agent spawned.
fn last_child(pid: i32) -> Option<i32> {
let contents = std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children")).ok()?;
contents.split_whitespace().last()?.parse().ok()
fn read_stat(pid: i32) -> Option<Stat> {
parse_stat(&std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)
}
/// Read the process state char from `/proc/<pid>/stat`.
fn read_state(pid: i32) -> Option<char> {
parse_stat_state(&std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?)
}
/// Extract the state field from a `/proc/<pid>/stat` line. The format is
/// `pid (comm) STATE ...`; `comm` can itself contain spaces and parens, so the
/// state is the first non-space char *after the final `)`*.
fn parse_stat_state(stat: &str) -> Option<char> {
/// 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(')')?;
stat[close + 1..].trim_start().chars().next()
let mut rest = stat[close + 1..].split_whitespace();
let state = rest.next()?.chars().next()?;
let _ppid = rest.next()?;
let pgrp = rest.next()?.parse().ok()?;
let _session = rest.next()?;
let _tty_nr = rest.next()?;
let tpgid = rest.next()?.parse().ok()?;
Some(Stat { state, pgrp, tpgid })
}
/// Whether `pid`'s `wchan` (the kernel symbol it's blocked in) is a timer
@@ -94,19 +159,51 @@ fn fd_points_at_tty(pid: i32, tty: &str) -> bool {
mod tests {
use super::*;
#[test]
fn parse_stat_state_handles_simple_and_tricky_comm() {
assert_eq!(parse_stat_state("1234 (bash) S 1 1234 ..."), Some('S'));
assert_eq!(parse_stat_state("1234 (sleep) S 1 ..."), Some('S'));
assert_eq!(parse_stat_state("99 (cat) R 1 ..."), Some('R'));
// comm with spaces and a paren — only the final ')' delimits it.
assert_eq!(parse_stat_state("42 (weird (name)) D 1 42 ..."), Some('D'));
/// `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_state_rejects_garbage() {
assert_eq!(parse_stat_state("not a stat line"), None);
assert_eq!(parse_stat_state("123 (comm)"), None);
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]
@@ -121,9 +218,46 @@ mod tests {
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 → every read fails → false (fall back to Idle).
assert!(!awaiting_input(i32::MAX, "/dev/pts/0"));
// 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()
));
}
}
+35 -6
View File
@@ -39,9 +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. Sourced from the shared [`maestro_config::BroadcastTuning`]
/// terminal anyway. Sourced from [`maestro_config::PTY_CAPACITY`]
/// (NAP-273).
pub const PTY_BROADCAST_CAPACITY: usize = maestro_config::BroadcastTuning::DEFAULT.pty_capacity;
pub const PTY_BROADCAST_CAPACITY: usize = maestro_config::PTY_CAPACITY;
/// Default replay-buffer size — roughly one screenful for a typical 24×80
/// terminal plus reasonable scrollback. Override via
@@ -58,7 +58,9 @@ 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
@@ -100,7 +102,7 @@ impl PtyBridge {
});
Ok(Self {
fifo_path,
sender,
sender: Some(sender),
replay,
last_activity,
task,
@@ -123,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
@@ -134,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 {
@@ -1,773 +0,0 @@
//! Outbound WebSocket client to the always-on registry.
//!
//! Holds a single persistent connection. On disconnect, reconnects with
//! exponential backoff. Per connection:
//!
//! 1. Send `Hello { daemon_id, hostname, version, token }`.
//! 2. Send `SessionsSnapshot { sessions: snapshot() }`.
//! 3. Drive a `select!` loop that pumps:
//! - inbound `CreateSession` / `KillSession` → `SessionManager` → reply
//! - outbound `SessionAdded/Removed/Updated` from the local event bus,
//! except the event that an in-flight RPC reply already conveys, which is
//! swallowed so the registry writes each change once (see [`RpcCovered`]).
//! - a 10s `Heartbeat` ticker.
use std::collections::HashSet;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
DaemonInbound, DaemonOutbound, PerimeterCandidate, RpcResult, PROTOCOL_VERSION,
};
use tokio::sync::broadcast::error::RecvError;
use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
use tokio::sync::mpsc;
use crate::broker::BrokerHandle;
use crate::catalog_mirror::CatalogMirror;
use crate::session::{SessionEvent, SessionManager};
/// Per-message heartbeat interval. Less than the registry's 30s timeout, with
/// headroom for jitter. Spec §"Wire protocol (D↔R)".
pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
/// Bounds on the reconnect backoff.
const BACKOFF_MIN: Duration = Duration::from_millis(250);
const BACKOFF_MAX: Duration = Duration::from_secs(30);
/// Session ids whose next lifecycle event the registry already learns about via
/// an RPC reply, so forwarding the matching async `SessionAdded` /
/// `SessionRemoved` would be a redundant *second* write to the registry catalog
/// (NAP-268).
///
/// A registry-driven `CreateSession` / `KillSession` is reported back to the
/// registry in two ways: the `CreateSessionResult` / `KillSessionResult` reply
/// (which the registry applies eagerly), *and* the async lifecycle event this
/// daemon emits on its own `SessionManager` stream. Both used to land, so the
/// registry's catalog write had to be idempotent to absorb the duplicate. We
/// drop the duplicate at the source instead: when an RPC creates or kills a
/// session, its id is parked here and the matching async event is swallowed
/// (consumed exactly once) before it reaches the registry. Spontaneous changes
/// — sessions created via the daemon's local HTTP API, or reaped on their own —
/// are not parked and still forward normally, since the registry has no other
/// way to learn about them.
///
/// Tracked per-connection: a reconnect starts fresh (the post-handshake
/// `SessionsSnapshot` reconciles the registry's full set anyway).
#[derive(Debug, Default)]
struct RpcCovered {
added: HashSet<Uuid>,
removed: HashSet<Uuid>,
}
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("ws error: {0}")]
Ws(#[from] tokio_tungstenite::tungstenite::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("registry handshake failed: {0}")]
Handshake(String),
}
#[derive(Debug)]
pub struct RegistryClient {
registry_url: String,
daemon_id: String,
hostname: String,
daemon_url: String,
home_dir: String,
token: String,
/// Operator-set perimeter (id + reachable address) advertised in heartbeats
/// so peer daemons can dial this one over T2 when they share the same
/// perimeter. Typed so "both or neither" holds by construction (NAP-272).
perimeter: Option<PerimeterCandidate>,
manager: SessionManager,
/// Local mirror of the global catalog (peer daemons + peer sessions),
/// populated from `DaemonInbound::CatalogEvent` frames the registry
/// fans out.
catalog: CatalogMirror,
/// Shared with HTTP handlers (NAP-247): when one calls `resolve_attach`,
/// we route the request through this client's control link and route the
/// reply back via the handle's pending map.
broker: BrokerHandle,
/// Outbound channel from `BrokerHandle::resolve_attach` (and future broker
/// requests). Drained inside `connect_once` and forwarded into the WS.
broker_rx: mpsc::Receiver<DaemonOutbound>,
}
impl RegistryClient {
#[allow(clippy::too_many_arguments)]
pub fn new(
registry_url: impl Into<String>,
daemon_id: impl Into<String>,
hostname: impl Into<String>,
daemon_url: impl Into<String>,
home_dir: impl Into<String>,
token: impl Into<String>,
manager: SessionManager,
) -> Self {
Self::new_with_bus(
registry_url,
daemon_id,
hostname,
daemon_url,
home_dir,
token,
manager,
None,
)
}
/// Variant that wires a [`LocalEventBus`](crate::LocalEventBus) into the
/// catalog mirror so peer deltas reach `WS /api/events` (NAP-248).
#[allow(clippy::too_many_arguments)]
pub fn new_with_bus(
registry_url: impl Into<String>,
daemon_id: impl Into<String>,
hostname: impl Into<String>,
daemon_url: impl Into<String>,
home_dir: impl Into<String>,
token: impl Into<String>,
manager: SessionManager,
bus: Option<crate::LocalEventBus>,
) -> Self {
let (broker, broker_rx) = BrokerHandle::new();
let catalog = match &bus {
Some(b) => CatalogMirror::new().with_bus(b.clone()),
None => CatalogMirror::new(),
};
Self {
registry_url: registry_url.into(),
daemon_id: daemon_id.into(),
hostname: hostname.into(),
daemon_url: daemon_url.into(),
home_dir: home_dir.into(),
token: token.into(),
perimeter: None,
manager,
catalog,
broker,
broker_rx,
}
}
/// Cheap clone of the broker handle for HTTP handlers (NAP-247). Sends
/// here are pumped through this client's control link.
pub fn broker(&self) -> BrokerHandle {
self.broker.clone()
}
/// Cheap handle to the daemon's peer-catalog mirror. Clone freely;
/// internal state is `Arc<Mutex<…>>`.
pub fn catalog(&self) -> CatalogMirror {
self.catalog.clone()
}
/// Set the perimeter advertised in heartbeats. The typed argument encodes
/// "both id and addr, or neither" — a private address without a perimeter
/// id is no longer representable (NAP-272).
pub fn with_perimeter(mut self, perimeter: Option<PerimeterCandidate>) -> Self {
self.perimeter = perimeter;
self
}
/// Run forever, reconnecting with exponential backoff between attempts.
/// Returns only if cancelled by the caller dropping the task.
pub async fn run(mut self) {
let mut backoff = BACKOFF_MIN;
loop {
let outcome = self.connect_once().await;
// Whatever happened, the link is down now — make the broker fail
// fast until the next successful handshake re-marks it up (NAP-263).
self.broker.set_connected(false);
match outcome {
Ok(()) => {
tracing::info!("registry connection ended cleanly");
backoff = BACKOFF_MIN;
}
Err(e) => {
tracing::warn!(error = %e, ?backoff, "registry connection failed");
}
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(BACKOFF_MAX);
}
}
async fn connect_once(&mut self) -> Result<(), ClientError> {
let (ws, _) = tokio_tungstenite::connect_async(&self.registry_url).await?;
let (mut sink, mut stream) = ws.split();
// Hello + wait for Ack
let hello = DaemonOutbound::Hello {
daemon_id: self.daemon_id.clone(),
hostname: self.hostname.clone(),
version: PROTOCOL_VERSION.into(),
token: self.token.clone(),
daemon_url: self.daemon_url.clone(),
home_dir: self.home_dir.clone(),
};
sink.send(Message::Text(serde_json::to_string(&hello)?))
.await?;
match stream.next().await {
Some(Ok(Message::Text(t))) => match serde_json::from_str::<DaemonInbound>(&t)? {
DaemonInbound::Ack => {}
DaemonInbound::Error(e) => {
return Err(ClientError::Handshake(format!("{}: {}", e.code, e.message)));
}
other => {
return Err(ClientError::Handshake(format!(
"unexpected first frame {other:?}"
)))
}
},
other => {
return Err(ClientError::Handshake(format!(
"no hello-ack frame: {other:?}"
)))
}
}
// Link is up — let the broker queue ResolveAttach frames again
// (NAP-263). Cleared in `run` once this connection ends.
self.broker.set_connected(true);
// Subscribe to the session-event stream BEFORE taking the snapshot. A
// reap or kill that races the handshake would otherwise slip between
// the snapshot and the subscription — present in neither — leaving the
// registry advertising a session this daemon no longer has until the
// next reconnect. `local_api::pump_events` subscribes-then-snapshots
// for the browser/CLI event WS for exactly this reason.
let mut events = self.manager.events();
let snapshot = DaemonOutbound::SessionsSnapshot {
sessions: self.manager.snapshot().await,
};
sink.send(Message::Text(serde_json::to_string(&snapshot)?))
.await?;
// The first interval tick fires immediately, so the registry gets a
// perimeter advertisement (NAP-247) right after the handshake instead
// of waiting a full HEARTBEAT_INTERVAL for `update_perimeter` to land.
let mut hb = tokio::time::interval(HEARTBEAT_INTERVAL);
hb.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// Session ids whose async lifecycle event the in-flight RPC reply
// already conveys to the registry — swallowed here so the registry
// writes each change exactly once (NAP-268).
let mut covered = RpcCovered::default();
loop {
tokio::select! {
msg = stream.next() => {
match msg {
None => break,
Some(Err(e)) => return Err(e.into()),
Some(Ok(Message::Close(_))) => break,
Some(Ok(Message::Text(text))) => {
self.handle_inbound(&text, &mut sink, &mut covered).await?;
}
Some(Ok(_)) => {}
}
}
ev = events.recv() => {
if !forward_session_event(&self.manager, ev, &mut sink, &mut covered).await? {
break;
}
}
_ = hb.tick() => {
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::Heartbeat {
perimeter: self.perimeter.clone(),
},
)?)).await?;
}
Some(broker_msg) = self.broker_rx.recv() => {
// Outbound broker requests (NAP-247) — e.g. ResolveAttach.
sink.send(Message::Text(serde_json::to_string(&broker_msg)?))
.await?;
}
}
}
Ok(())
}
async fn handle_inbound<S>(
&self,
text: &str,
sink: &mut S,
covered: &mut RpcCovered,
) -> Result<(), ClientError>
where
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
{
let parsed: DaemonInbound = match serde_json::from_str(text) {
Ok(p) => p,
Err(e) => {
// A single malformed frame must not drop the registry link —
// log and skip it (NAP-269).
tracing::warn!(error = %e, "malformed registry frame — skipping");
return Ok(());
}
};
match parsed {
DaemonInbound::Ack => {}
DaemonInbound::Error(e) => {
tracing::warn!(code = %e.code, message = %e.message, "registry-reported error");
}
DaemonInbound::CreateSession { reply_to, request } => {
let result = match self.manager.create(request).await {
Ok(s) => {
// The reply carries the session, so the registry applies
// it on receipt; swallow the async `SessionAdded` this
// create just queued on our event stream (NAP-268).
covered.added.insert(s.id);
RpcResult::Ok { value: s }
}
Err(e) => RpcResult::Err {
error: e.to_error_info(),
},
};
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::CreateSessionResult { reply_to, result },
)?))
.await?;
}
DaemonInbound::KillSession { reply_to, id } => {
let result = match self.manager.kill(id).await {
Ok(()) => {
// The reply confirms the removal; swallow the async
// `SessionRemoved` it queued so the registry drops the
// session exactly once (NAP-268).
covered.removed.insert(id);
RpcResult::Ok { value: () }
}
Err(e) => RpcResult::Err {
error: e.to_error_info(),
},
};
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::KillSessionResult { reply_to, result },
)?))
.await?;
}
DaemonInbound::ResolveAttachResult { reply_to, result } => {
self.broker.complete_resolve(reply_to, result).await;
}
DaemonInbound::ForwardCreateResult { reply_to, result } => {
self.broker.complete_forward_create(reply_to, result).await;
}
DaemonInbound::ForwardKillResult { reply_to, result } => {
self.broker.complete_forward_kill(reply_to, result).await;
}
DaemonInbound::ListDir { reply_to, path } => {
// List a directory on this host for a client's spawn-form
// autocomplete (NAP-329). Resolved against this daemon's home.
let result = match crate::dirlist::list_dir(&self.home_dir, &path) {
Ok(listing) => RpcResult::Ok { value: listing },
Err(error) => RpcResult::Err { error },
};
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::ListDirResult { reply_to, result },
)?))
.await?;
}
DaemonInbound::ForwardListDirResult { reply_to, result } => {
self.broker
.complete_forward_list_dir(reply_to, result)
.await;
}
DaemonInbound::RecentDirs { reply_to } => {
// Answer a peer's recent-dirs query from this host's spawn
// history (#26). Daemon-wide, so no path to resolve.
let value = crate::spawn_history::to_wire(self.manager.recent_dirs());
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::RecentDirsResult {
reply_to,
result: RpcResult::Ok { value },
},
)?))
.await?;
}
DaemonInbound::ForwardRecentDirsResult { reply_to, result } => {
self.broker
.complete_forward_recent_dirs(reply_to, result)
.await;
}
DaemonInbound::GitRoot { reply_to, path } => {
// Probe the enclosing repo of `path` on this host (#26),
// resolving the abbreviated path against this daemon's home the
// same way `ListDir` does.
let dir = crate::dirlist::resolve(&self.home_dir, &path);
let value = maestro_protocol::GitRoot {
root: crate::gitroot::find_root(&dir),
};
sink.send(Message::Text(serde_json::to_string(
&DaemonOutbound::GitRootResult {
reply_to,
result: RpcResult::Ok { value },
},
)?))
.await?;
}
DaemonInbound::ForwardGitRootResult { reply_to, result } => {
self.broker
.complete_forward_git_root(reply_to, result)
.await;
}
DaemonInbound::RelaySetup { .. } => {
// T3 (relay) is gated on NAP-238. Lands here only so newer
// registries sending it don't crash this daemon on parse.
tracing::debug!("RelaySetup received but T3 not implemented (NAP-238 pending)");
}
DaemonInbound::CatalogEvent { event } => {
// NAP-246: peer-filtered catalog delta from the registry.
self.catalog.apply(event).await;
}
DaemonInbound::Unknown => {
// A newer registry emitted a frame type this build doesn't know.
// Skip it rather than dropping the link (NAP-269).
tracing::debug!("unknown registry frame — skipping (forward-compat, NAP-269)");
}
}
Ok(())
}
}
/// Translate one item from the session-event stream into an outbound frame on
/// `sink`. Returns `Ok(false)` once the stream has closed and the caller
/// should stop the loop.
///
/// An `Added` / `Removed` whose id is parked in `covered` was driven by an RPC
/// whose reply already reports the change to the registry; it is consumed and
/// *not* forwarded, so the registry writes each change exactly once (NAP-268).
///
/// On `Lagged` the daemon's own view of its sessions skipped frames — which
/// may include a `Removed`. Emitting nothing (the previous behaviour) left the
/// registry advertising a now-dead session until the next reconnect, since the
/// registry only reconciles a daemon's full session set on `apply_snapshot`.
/// So we re-send a full `SessionsSnapshot`, which the registry applies by
/// replacing this daemon's set — healing the missed removal immediately. The
/// snapshot is the full truth, so any still-parked `covered` ids are stale and
/// cleared.
async fn forward_session_event<S>(
manager: &SessionManager,
ev: Result<SessionEvent, RecvError>,
sink: &mut S,
covered: &mut RpcCovered,
) -> Result<bool, ClientError>
where
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
{
let frame = match ev {
Ok(SessionEvent::Added(session)) => {
if covered.added.remove(&session.id) {
return Ok(true);
}
DaemonOutbound::SessionAdded { session }
}
Ok(SessionEvent::Removed(id)) => {
if covered.removed.remove(&id) {
return Ok(true);
}
DaemonOutbound::SessionRemoved { id }
}
Ok(SessionEvent::Updated(session)) => DaemonOutbound::SessionUpdated { session },
Err(RecvError::Lagged(skipped)) => {
tracing::warn!(
skipped,
"session-event stream lagged — resyncing registry with a full snapshot"
);
covered.added.clear();
covered.removed.clear();
DaemonOutbound::SessionsSnapshot {
sessions: manager.snapshot().await,
}
}
Err(RecvError::Closed) => return Ok(false),
};
sink.send(Message::Text(serde_json::to_string(&frame)?))
.await?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::pin::Pin;
use std::task::{Context, Poll};
use uuid::Uuid;
/// Minimal in-memory sink that records every frame sent to it, so we can
/// assert exactly what `forward_session_event` emits.
#[derive(Default)]
struct VecSink(Vec<Message>);
impl futures_util::Sink<Message> for VecSink {
type Error = tokio_tungstenite::tungstenite::Error;
fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
self.get_mut().0.push(item);
Ok(())
}
fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
}
/// A `SessionManager` backed by temp dirs. It never spawns tmux here — the
/// tests only drive `forward_session_event`, which at most reads the
/// (empty) in-memory session map via `snapshot()`.
fn test_manager() -> (SessionManager, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let mgr = SessionManager::new(
"test-daemon",
crate::tmux::Tmux::new(),
crate::sidecar::SidecarStore::new(dir.path().join("sessions")),
crate::worktree::WorktreeManager::new(dir.path().join("worktrees")),
dir.path().join("fifos"),
);
(mgr, dir)
}
fn decode(sink: &VecSink) -> Vec<DaemonOutbound> {
sink.0
.iter()
.map(|m| match m {
Message::Text(t) => serde_json::from_str(t).unwrap(),
other => panic!("unexpected frame {other:?}"),
})
.collect()
}
fn sample_session(id: Uuid) -> maestro_protocol::Session {
maestro_protocol::Session {
id,
daemon_id: "test-daemon".into(),
cwd: std::path::PathBuf::from("/tmp"),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
status: maestro_protocol::SessionStatus::Running,
created_at: chrono::Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
// Regression test for NAP-291 (defect B): a lagged event stream may have
// dropped a `Removed`, so the daemon must resync the registry's full set
// rather than silently skip — otherwise a dead session lingers in the
// catalog until the next reconnect. The previous `continue` sent nothing.
#[tokio::test]
async fn lagged_stream_resyncs_with_full_snapshot() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let cont = forward_session_event(
&mgr,
Err(RecvError::Lagged(9)),
&mut sink,
&mut RpcCovered::default(),
)
.await
.unwrap();
assert!(cont, "lag is recoverable; the loop must continue");
let frames = decode(&sink);
assert!(
matches!(frames.as_slice(), [DaemonOutbound::SessionsSnapshot { .. }]),
"a lagged stream must resync via SessionsSnapshot; got {frames:?}",
);
}
#[tokio::test]
async fn removed_event_forwards_session_removed() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let id = Uuid::new_v4();
let cont = forward_session_event(
&mgr,
Ok(SessionEvent::Removed(id)),
&mut sink,
&mut RpcCovered::default(),
)
.await
.unwrap();
assert!(cont);
let frames = decode(&sink);
assert!(
matches!(frames.as_slice(), [DaemonOutbound::SessionRemoved { id: got }] if *got == id),
"got {frames:?}",
);
}
#[tokio::test]
async fn closed_stream_signals_break_without_sending() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let cont = forward_session_event(
&mgr,
Err(RecvError::Closed),
&mut sink,
&mut RpcCovered::default(),
)
.await
.unwrap();
assert!(!cont, "a closed stream must signal the loop to break");
assert!(sink.0.is_empty(), "nothing should be sent on close");
}
// NAP-268: an RPC-driven create/kill reports the change to the registry via
// its reply, so the matching async lifecycle event must be swallowed — never
// forwarded — to keep the registry's catalog write single (not idempotent).
#[tokio::test]
async fn rpc_covered_added_is_swallowed_once() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let id = Uuid::new_v4();
let mut covered = RpcCovered::default();
covered.added.insert(id);
let cont = forward_session_event(
&mgr,
Ok(SessionEvent::Added(sample_session(id))),
&mut sink,
&mut covered,
)
.await
.unwrap();
assert!(cont, "swallowing an event must not stop the loop");
assert!(
sink.0.is_empty(),
"an RPC-covered SessionAdded must not reach the registry; got {:?}",
decode(&sink),
);
assert!(
!covered.added.contains(&id),
"the parked id must be consumed exactly once",
);
}
#[tokio::test]
async fn rpc_covered_removed_is_swallowed_once() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let id = Uuid::new_v4();
let mut covered = RpcCovered::default();
covered.removed.insert(id);
let cont =
forward_session_event(&mgr, Ok(SessionEvent::Removed(id)), &mut sink, &mut covered)
.await
.unwrap();
assert!(cont);
assert!(
sink.0.is_empty(),
"an RPC-covered SessionRemoved must not reach the registry; got {:?}",
decode(&sink),
);
assert!(!covered.removed.contains(&id));
}
// A spontaneous change (e.g. a session created via the daemon's local HTTP
// API, or reaped on its own) is the registry's only signal, so it must still
// forward even while *other* ids are parked.
#[tokio::test]
async fn uncovered_added_still_forwards() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let parked = Uuid::new_v4();
let spontaneous = Uuid::new_v4();
let mut covered = RpcCovered::default();
covered.added.insert(parked);
let cont = forward_session_event(
&mgr,
Ok(SessionEvent::Added(sample_session(spontaneous))),
&mut sink,
&mut covered,
)
.await
.unwrap();
assert!(cont);
let frames = decode(&sink);
assert!(
matches!(
frames.as_slice(),
[DaemonOutbound::SessionAdded { session }] if session.id == spontaneous
),
"an unparked create must forward; got {frames:?}",
);
assert!(
covered.added.contains(&parked),
"an unrelated parked id must be left intact",
);
}
// A second event for the same id (e.g. the RPC was swallowed, then the
// session is reaped) is no longer covered and forwards normally.
#[tokio::test]
async fn covered_consumed_then_subsequent_event_forwards() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let id = Uuid::new_v4();
let mut covered = RpcCovered::default();
covered.added.insert(id);
// First event: the RPC-covered create — swallowed.
forward_session_event(
&mgr,
Ok(SessionEvent::Added(sample_session(id))),
&mut sink,
&mut covered,
)
.await
.unwrap();
assert!(sink.0.is_empty());
// Second event: a spontaneous removal of that same session — forwarded,
// since only the create was parked.
forward_session_event(&mgr, Ok(SessionEvent::Removed(id)), &mut sink, &mut covered)
.await
.unwrap();
let frames = decode(&sink);
assert!(
matches!(frames.as_slice(), [DaemonOutbound::SessionRemoved { id: got }] if *got == id),
"got {frames:?}",
);
}
// A lag resync is the registry's full truth, so any still-parked ids are
// stale and must be cleared (else a later unrelated event could be wrongly
// swallowed).
#[tokio::test]
async fn lag_resync_clears_parked_ids() {
let (mgr, _dir) = test_manager();
let mut sink = VecSink::default();
let mut covered = RpcCovered::default();
covered.added.insert(Uuid::new_v4());
covered.removed.insert(Uuid::new_v4());
forward_session_event(&mgr, Err(RecvError::Lagged(3)), &mut sink, &mut covered)
.await
.unwrap();
assert!(
covered.added.is_empty() && covered.removed.is_empty(),
"a full-snapshot resync must clear stale parked ids",
);
}
}
+147 -89
View File
@@ -2,7 +2,7 @@
//! worktrees, and PTY bridges.
//!
//! Owns the daemon's authoritative in-memory map of live sessions and is the
//! single point through which the registry client and the per-session WS
//! single point through which the API handlers and the per-session WS
//! handler interact with tmux.
use std::collections::{HashMap, HashSet};
@@ -12,34 +12,27 @@ use std::time::Duration;
use chrono::Utc;
use maestro_protocol::{
ActivityState, CreateSessionRequest, ErrorInfo, Session, SessionStatus, WorktreeSpec,
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus,
WorktreeSpec,
};
use tokio::sync::{broadcast, Mutex};
use uuid::Uuid;
use crate::panes::Panes;
use crate::pty::{pipe_pane_command, PtyBridge};
use crate::sidecar::{SidecarError, SidecarStore};
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
use crate::tmux::{Tmux, TmuxError};
use crate::tmux::TmuxError;
use crate::worktree::{WorktreeError, WorktreeManager};
/// Capacity of the session-events broadcast channel. The registry client
/// listens; if it lags by this much we just drop frames (the registry already
/// reconciles on `Hello` via the snapshot). Sourced from the shared
/// [`maestro_config::BroadcastTuning`] so all broadcast channels track one
/// definition (NAP-273).
pub const EVENTS_CAPACITY: usize = maestro_config::BroadcastTuning::DEFAULT.events_capacity;
/// Capacity of the session-events broadcast channel. A subscriber that falls
/// this far behind gets `RecvError::Lagged`, which `/api/events` forwards as
/// [`maestro_protocol::CatalogEvent::Lagged`] so that client re-fetches.
pub const EVENTS_CAPACITY: usize = maestro_config::EVENTS_CAPACITY;
/// How often the liveness watcher polls tmux for sessions that disappeared.
pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
#[derive(Debug, Clone)]
pub enum SessionEvent {
Added(Session),
Removed(Uuid),
Updated(Session),
}
/// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)]
pub struct SessionRecord {
@@ -67,8 +60,8 @@ pub enum SessionError {
Io(#[from] std::io::Error),
#[error("session not found: {0}")]
NotFound(Uuid),
#[error("request targets daemon `{wanted}`, this daemon is `{ours}`")]
WrongDaemon { wanted: String, ours: String },
#[error("invalid environment variable name: {0:?}")]
InvalidEnv(String),
}
impl SessionError {
@@ -79,7 +72,7 @@ impl SessionError {
SessionError::Sidecar(_) => "sidecar_failed",
SessionError::Io(_) => "io_failed",
SessionError::NotFound(_) => "not_found",
SessionError::WrongDaemon { .. } => "wrong_daemon",
SessionError::InvalidEnv(_) => "invalid_env",
};
ErrorInfo {
code: code.into(),
@@ -96,8 +89,7 @@ pub struct SessionManager {
#[derive(Debug)]
struct Inner {
daemon_id: String,
tmux: Tmux,
tmux: Arc<dyn Panes>,
sidecar: SidecarStore,
worktree_mgr: WorktreeManager,
fifo_dir: PathBuf,
@@ -109,19 +101,17 @@ struct Inner {
/// but disabled (`0`-day) history no-ops internally.
spawn_history: Option<SpawnHistory>,
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<SessionEvent>,
events: broadcast::Sender<CatalogEvent>,
}
impl SessionManager {
pub fn new(
daemon_id: impl Into<String>,
tmux: Tmux,
tmux: Arc<dyn Panes>,
sidecar: SidecarStore,
worktree_mgr: WorktreeManager,
fifo_dir: PathBuf,
) -> Self {
Self::new_with_replay(
daemon_id,
tmux,
sidecar,
worktree_mgr,
@@ -133,8 +123,7 @@ impl SessionManager {
/// Variant that lets the caller override the per-session replay buffer
/// cap. Pass `0` to disable replay (subscribers see only live frames).
pub fn new_with_replay(
daemon_id: impl Into<String>,
tmux: Tmux,
tmux: Arc<dyn Panes>,
sidecar: SidecarStore,
worktree_mgr: WorktreeManager,
fifo_dir: PathBuf,
@@ -143,7 +132,6 @@ impl SessionManager {
let (events, _) = broadcast::channel(EVENTS_CAPACITY);
Self {
inner: Arc::new(Inner {
daemon_id: daemon_id.into(),
tmux,
sidecar,
worktree_mgr,
@@ -157,23 +145,21 @@ impl SessionManager {
}
/// Attach a durable spawn-history log (#24). Builder, called once right
/// after construction while the `Arc<Inner>` is uniquely owned — so
/// [`Arc::get_mut`] succeeds. A no-op (logged) on the off chance the handle
/// was already cloned.
/// after construction while the `Arc<Inner>` is uniquely owned.
///
/// # Panics
///
/// If the handle has already been cloned. That is a construction-order
/// mistake, and the alternative — logging and carrying on — produces a
/// daemon whose "recent dirs" picker is permanently empty with nothing to
/// show for it.
pub fn with_spawn_history(mut self, history: SpawnHistory) -> Self {
match Arc::get_mut(&mut self.inner) {
Some(inner) => inner.spawn_history = Some(history),
None => tracing::error!(
"with_spawn_history called after the SessionManager was cloned; ignoring"
),
}
let inner = Arc::get_mut(&mut self.inner)
.expect("with_spawn_history must be called before the SessionManager is cloned");
inner.spawn_history = Some(history);
self
}
pub fn daemon_id(&self) -> &str {
&self.inner.daemon_id
}
/// Recent working dirs for the spawn form's picker (#24), newest-first and
/// deduped by dir. Empty when no history is wired or it's disabled.
pub fn recent_dirs(&self) -> Vec<RecentDir> {
@@ -184,7 +170,9 @@ impl SessionManager {
.unwrap_or_default()
}
pub fn events(&self) -> broadcast::Receiver<SessionEvent> {
/// Subscribe to session deltas. Each subscriber gets its own lag domain —
/// a slow client makes only itself resync.
pub fn events(&self) -> broadcast::Receiver<CatalogEvent> {
self.inner.events.subscribe()
}
@@ -216,15 +204,14 @@ impl SessionManager {
self.inner.sessions.lock().await.contains_key(&id)
}
/// Current status of a session, or `None` if we do not know it.
pub async fn status(&self, id: Uuid) -> Option<SessionStatus> {
let sessions = self.inner.sessions.lock().await;
sessions.get(&id).map(|r| r.session.status)
}
/// Spawn a new session. On success returns the freshly-created `Session`.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
if req.daemon_id != self.inner.daemon_id {
return Err(SessionError::WrongDaemon {
wanted: req.daemon_id,
ours: self.inner.daemon_id.clone(),
});
}
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
Some(WorktreeSpec {
base_repo, branch, ..
@@ -262,12 +249,21 @@ impl SessionManager {
return Err(e.into());
}
};
let session_env = match sanitize_env(&req.env) {
Ok(env) => env,
Err(e) => {
let _ = std::fs::remove_file(&fifo_path);
self.rollback_worktree(&worktree_base_repo, &worktree_path);
return Err(e);
}
};
let spawned = {
let tmux = self.inner.tmux.clone();
let cwd = effective_cwd.clone();
let command = req.command.clone();
let sink = pipe_pane_command(&fifo_path);
tmux_blocking(move || tmux.new_session_piped(id, &cwd, &command, &sink)).await
let env = session_env.clone();
tmux_blocking(move || tmux.new_session_piped(id, &cwd, &command, &env, &sink)).await
};
if let Err(e) = spawned {
// `bridge` drops at the end of this block, closing the FIFO reader.
@@ -285,7 +281,6 @@ impl SessionManager {
let session = Session {
id,
daemon_id: self.inner.daemon_id.clone(),
cwd: effective_cwd,
command: req.command,
env: req.env,
@@ -325,7 +320,9 @@ impl SessionManager {
},
);
}
let _ = self.inner.events.send(SessionEvent::Added(session.clone()));
let _ = self.inner.events.send(CatalogEvent::SessionAdded {
session: session.clone(),
});
// Best-effort: log the spawn for the recents picker (#24). A log-write
// failure must never fail an otherwise-successful spawn, so the error
@@ -362,13 +359,18 @@ impl SessionManager {
/// Kill a session and remove its on-disk artifacts. The worktree is only
/// pruned if the create request set `cleanup_on_kill: true`.
///
/// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a
/// session tmux already lost fails, and that failure is not interesting.
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
let removed = {
let mut sessions = self.inner.sessions.lock().await;
sessions.remove(&id)
};
let record = removed.ok_or(SessionError::NotFound(id))?;
cleanup_record(&self.inner, id, record, true).await;
let alive = record.session.status == SessionStatus::Running;
cleanup_record(&self.inner, id, record, alive).await;
Ok(())
}
@@ -415,11 +417,10 @@ impl SessionManager {
};
let mut adopted = 0usize;
for id in live {
let mut session = match self.inner.sidecar.read(id) {
let session = match self.inner.sidecar.read(id) {
Ok(Some(s)) => s,
_ => Session {
id,
daemon_id: self.inner.daemon_id.clone(),
cwd: PathBuf::from("/"),
command: String::new(),
env: Default::default(),
@@ -434,19 +435,6 @@ impl SessionManager {
current_cwd: None,
},
};
// The sidecar persists whatever daemon_id was current when the
// session was created. If the operator renamed the daemon between
// starts, that id is stale — re-advertise under our own id so the
// session stays routable (NAP-264).
if session.daemon_id != self.inner.daemon_id {
tracing::warn!(
%id,
sidecar_daemon_id = %session.daemon_id,
current_daemon_id = %self.inner.daemon_id,
"recovered session had a stale daemon_id; re-advertising under the current id"
);
session.daemon_id = self.inner.daemon_id.clone();
}
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
let bridge =
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
@@ -470,15 +458,23 @@ impl SessionManager {
bridge,
},
);
let _ = self.inner.events.send(SessionEvent::Added(session));
let _ = self
.inner
.events
.send(CatalogEvent::SessionAdded { session });
adopted += 1;
}
Ok(adopted)
}
/// Forward keystrokes to a session's pane. A dead session is read-only —
/// its pane is gone, so this is a no-op rather than a tmux failure the
/// client would have to interpret.
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
if !self.has(id).await {
return Err(SessionError::NotFound(id));
match self.status(id).await {
None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Ok(()),
Some(SessionStatus::Running) => {}
}
let tmux = self.inner.tmux.clone();
let bytes = bytes.to_vec();
@@ -487,9 +483,13 @@ impl SessionManager {
.map_err(SessionError::Tmux)
}
/// Resize a session's window. No-op for a dead session, as with
/// [`Self::send_keys`].
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
if !self.has(id).await {
return Err(SessionError::NotFound(id));
match self.status(id).await {
None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Ok(()),
Some(SessionStatus::Running) => {}
}
let tmux = self.inner.tmux.clone();
tmux_blocking(move || tmux.resize_window(id, cols, rows))
@@ -501,8 +501,26 @@ impl SessionManager {
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it
/// never stalls a tokio worker. `tmux` is a fork+exec that can take tens of
/// milliseconds on a loaded host, and `send_keys` rides the per-keystroke hot
/// path — running it inline would starve HTTP handlers, the registry WS, the
/// path — running it inline would starve HTTP handlers, the events WS, the
/// liveness watcher, and PTY readers (NAP-256).
/// Turn a request's environment into tmux `-e KEY=VAL` material.
///
/// A key containing `=` or NUL would be parsed by tmux as a different
/// assignment than the caller wrote, so it is rejected rather than silently
/// reinterpreted. `BTreeMap` so the resulting argv is deterministic.
fn sanitize_env(
env: &std::collections::HashMap<String, String>,
) -> Result<std::collections::BTreeMap<String, String>, SessionError> {
let mut out = std::collections::BTreeMap::new();
for (k, v) in env {
if k.is_empty() || k.contains('=') || k.contains('\0') || v.contains('\0') {
return Err(SessionError::InvalidEnv(k.clone()));
}
out.insert(k.clone(), v.clone());
}
Ok(out)
}
async fn tmux_blocking<T, F>(f: F) -> T
where
F: FnOnce() -> T + Send + 'static,
@@ -520,13 +538,22 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
// nothing to reap and no reason to spawn a tmux subprocess (this also
// avoids racing with the very first `new-session` that would otherwise
// be competing to start the tmux server).
// Only sessions we still believe are running can transition; a session
// already marked Dead must not re-emit an update on every 2s tick.
let known: Vec<Uuid> = {
let sessions = inner.sessions.lock().await;
if sessions.is_empty() {
return;
}
sessions.keys().copied().collect()
sessions
.iter()
.filter(|(_, r)| r.session.status == SessionStatus::Running)
.map(|(id, _)| *id)
.collect()
};
if known.is_empty() {
return;
}
let live_ids = {
let tmux = inner.tmux.clone();
@@ -542,7 +569,7 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
for id in known {
if !live.contains(&id) {
reap_dead_session(inner, id).await;
mark_session_dead(inner, id).await;
}
}
}
@@ -556,8 +583,22 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
if sessions.is_empty() {
return;
}
sessions.keys().copied().collect()
// A dead session has no pane to inspect; its last observed activity is
// kept as-is so the UI can still show what it was doing when it died.
sessions
.iter()
.filter(|(_, r)| r.session.status == SessionStatus::Running)
.map(|(id, _)| *id)
.collect()
};
if ids.is_empty() {
return;
}
// One `/proc` walk for the whole pass, rather than one per session. Reading
// it is pure filesystem work, so it goes on the blocking pool alongside the
// tmux calls below.
let groups = tmux_blocking(crate::proc::ProcGroups::snapshot).await;
for id in ids {
// The tmux pane lookup is a subprocess — keep it off the runtime worker
@@ -575,7 +616,7 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
}
};
// `/proc` inspection is cheap, but still done off the async lock.
let awaiting = crate::proc::awaiting_input(pane.pane_pid, &pane.tty);
let awaiting = crate::proc::awaiting_input(pane.pane_pid, &pane.tty, &groups);
let updated = {
let mut sessions = inner.sessions.lock().await;
@@ -607,24 +648,41 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
}
};
if let Some(session) = updated {
let _ = inner.events.send(SessionEvent::Updated(session));
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
}
}
}
/// Remove a session whose tmux session is already gone: drop the in-memory
/// record, delete the sidecar + FIFO, clean up its worktree if requested, and
/// fire `SessionEvent::Removed`. No tmux kill — there is nothing left to kill.
async fn reap_dead_session(inner: &Arc<Inner>, id: Uuid) {
let record = {
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
/// and announce it, keeping everything else intact.
///
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
/// still attach and read the replay buffer — the last thing a crashed agent
/// printed. Artifacts are only released when the operator dismisses the session
/// via [`SessionManager::kill`].
///
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
/// adopts only what tmux still holds.
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
let updated = {
let mut sessions = inner.sessions.lock().await;
sessions.remove(&id)
match sessions.get_mut(&id) {
Some(record) if record.session.status == SessionStatus::Running => {
record.session.status = SessionStatus::Dead;
// Release anyone attached; the scrollback survives for a later
// attach.
record.bridge.seal();
Some(record.session.clone())
}
_ => None,
}
};
let Some(record) = record else {
let Some(session) = updated else {
return;
};
tracing::info!(%id, "session vanished from tmux — reaping");
cleanup_record(inner, id, record, false).await;
tracing::info!(%id, "session vanished from tmux — marking dead");
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
}
/// Tear down a session's artifacts after it has already been removed from the
@@ -650,5 +708,5 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
let _ = inner.worktree_mgr.remove(base, path);
}
}
let _ = inner.events.send(SessionEvent::Removed(id));
let _ = inner.events.send(CatalogEvent::SessionRemoved { id });
}
+125 -7
View File
@@ -3,6 +3,7 @@
//! 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;
@@ -75,6 +76,10 @@ 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
}
@@ -96,7 +101,13 @@ impl Tmux {
let command = login_shell_command(env::var_os("SHELL").as_deref(), command);
let out = self
.cmd()
.args(new_session_args(&name, cwd, &command, None))
.args(new_session_args(
&name,
cwd,
&command,
&BTreeMap::new(),
None,
))
.output()?;
check(out)
}
@@ -112,13 +123,20 @@ impl Tmux {
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, Some(sink_command)))
.args(new_session_args(
&name,
cwd,
&command,
session_env,
Some(sink_command),
))
.output()?;
check(out)
}
@@ -263,12 +281,22 @@ impl Tmux {
}
}
/// Phrasings tmux uses for "there is no server on this socket". They vary by
/// version, and `list_sessions` turning one of these into an `Err` stops the
/// liveness watcher reaping for that pass — so a missing phrasing is silent.
const NO_SERVER_PHRASES: &[&str] = &[
"no server running",
"no sessions",
"error connecting",
// tmux >= 3.7 when the server shuts down while this client is connected,
// which is exactly what killing the last session does.
"server exited unexpectedly",
];
/// True if tmux's stderr indicates "there is no tmux server running on this
/// 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
@@ -360,6 +388,7 @@ fn new_session_args(
name: &str,
cwd: &Path,
command: &str,
env: &BTreeMap<String, String>,
pipe_sink: Option<&str>,
) -> Vec<OsString> {
let mut args = vec![
@@ -378,8 +407,14 @@ fn new_session_args(
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(";"),
@@ -407,6 +442,82 @@ 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();
@@ -440,7 +551,13 @@ mod tests {
#[test]
fn new_session_args_pass_256color_env_and_default_terminal() {
let args = new_session_args("maestro-abc", Path::new("/tmp/proj"), "sleep 30", None);
let args = new_session_args(
"maestro-abc",
Path::new("/tmp/proj"),
"sleep 30",
&BTreeMap::new(),
None,
);
let strs: Vec<&str> = args.iter().map(|s| s.to_str().unwrap()).collect();
assert_eq!(
strs,
@@ -496,6 +613,7 @@ mod tests {
"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();
+196
View File
@@ -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"
);
}
+85 -75
View File
@@ -1,5 +1,5 @@
//! End-to-end tests for the per-session attach WS endpoint:
//! - bad / good JWT
//! - unknown session is rejected
//! - PTY bytes round-trip through real tmux
//! - multi-attach: two clients see the same output
@@ -9,11 +9,10 @@ use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use chrono::{Duration as ChronoDuration, Utc};
use common::DaemonFixture;
use futures_util::{SinkExt, StreamExt};
use maestro_daemon::{attach, issue_jwt, JwtVerifier, PairAttachVerifier, SessionManager};
use maestro_protocol::{CreateSessionRequest, JwtClaims};
use maestro_daemon::{local_api, 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,15 @@ struct Harness {
_fx: DaemonFixture,
}
async fn spawn_daemon_ws(daemon_id: &str) -> Harness {
let fx = DaemonFixture::new(daemon_id);
async fn spawn_daemon_ws() -> Harness {
let fx = DaemonFixture::new();
let manager = fx.manager.clone();
let verifier = JwtVerifier::new(TOKEN, daemon_id);
let pair_verifier = PairAttachVerifier::new(TOKEN, daemon_id);
let router = attach::router(manager.clone(), verifier, pair_verifier);
let router = local_api::router(local_api::state_for(
manager.clone(),
"test-host",
"/home/test",
Vec::new(),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
@@ -58,25 +58,12 @@ async fn spawn_daemon_ws(daemon_id: &str) -> Harness {
}
}
fn token_for(session_id: Uuid, daemon_id: &str, valid_seconds: i64) -> String {
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 +73,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 +86,17 @@ async fn create_bash_session(h: &Harness) -> Uuid {
}
#[tokio::test]
async fn rejects_bad_token() {
let h = spawn_daemon_ws("laptop").await;
let id = create_bash_session(&h).await;
let err = connect_attach(h.addr, id, "not-a-jwt").await.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("401") || msg.contains("403"), "{msg}");
let _ = h.manager.kill(id).await;
}
#[tokio::test]
async fn rejects_token_for_different_session() {
let h = spawn_daemon_ws("laptop").await;
let real_id = create_bash_session(&h).await;
let other_id = Uuid::new_v4();
let token = token_for(other_id, "laptop", 300);
let err = connect_attach(h.addr, real_id, &token).await.unwrap_err();
assert!(format!("{err}").contains("403"));
let _ = h.manager.kill(real_id).await;
}
#[tokio::test]
async fn rejects_token_for_different_daemon() {
let h = spawn_daemon_ws("laptop").await;
let id = create_bash_session(&h).await;
let token = token_for(id, "dev-vm", 300);
let err = connect_attach(h.addr, id, &token).await.unwrap_err();
assert!(format!("{err}").contains("401"));
let _ = h.manager.kill(id).await;
async fn rejects_unknown_session() {
let h = spawn_daemon_ws().await;
let err = connect_attach(h.addr, Uuid::new_v4()).await.unwrap_err();
assert!(format!("{err}").contains("404"));
}
#[tokio::test]
async fn pty_round_trip_through_real_tmux() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
ws.send(WsMessage::Binary(b"echo MAESTRO_RT_OK\n".to_vec()))
.await
@@ -164,12 +125,11 @@ async fn pty_round_trip_through_real_tmux() {
#[tokio::test]
async fn multi_attach_broadcasts_to_every_client() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws_a = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws_b = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws_a = connect_attach(h.addr, id).await.unwrap();
let mut ws_b = connect_attach(h.addr, id).await.unwrap();
// give the broadcast subscribers a moment to register
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -216,10 +176,9 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() {
// Bug NAP-226: when bash exits, tmux destroys the session, the liveness
// watcher reaps it, and the broadcast channel closes — but the WS used to
// be dropped silently, leaving the client hung in raw mode.
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -251,10 +210,9 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() {
#[tokio::test]
async fn sends_close_frame_when_session_is_killed() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
// give the broadcast subscriber a moment to register before killing.
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -287,10 +245,9 @@ async fn sends_close_frame_when_session_is_killed() {
#[tokio::test]
async fn ping_text_frame_gets_pong() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
let ping = serde_json::to_string(&maestro_protocol::ClientControl::Ping).unwrap();
ws.send(WsMessage::Text(ping)).await.unwrap();
@@ -315,11 +272,10 @@ async fn ping_text_frame_gets_pong() {
async fn second_attach_sees_replay_buffer_of_prior_output() {
// NAP-229: a fresh attach must receive the buffered scrollback before
// any live frames, so reconnecting to an idle prompt isn't blank.
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let session = create_bash_session(&h).await;
let token = token_for(session, "laptop", 60);
let mut a = connect_attach(h.addr, session, &token).await.unwrap();
let mut a = connect_attach(h.addr, session).await.unwrap();
// Generate output the bash will echo back.
a.send(WsMessage::Binary(b"echo MAESTRO_REPLAY_MARK\n".to_vec()))
.await
@@ -349,7 +305,7 @@ async fn second_attach_sees_replay_buffer_of_prior_output() {
// Now attach a fresh client. It should see the prior MARK in the replay
// 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 +325,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:?}"
);
}
+7 -7
View File
@@ -7,6 +7,7 @@
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};
@@ -118,25 +119,24 @@ pub struct DaemonFixture {
}
impl DaemonFixture {
pub fn new(daemon_id: &str) -> Self {
Self::build(daemon_id, None)
pub fn new() -> Self {
Self::build(None)
}
/// Variant whose manager has a durable spawn history (#24) attached, with
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
pub fn new_with_history(daemon_id: &str, retention_days: u32) -> Self {
Self::build(daemon_id, Some(retention_days))
pub fn new_with_history(retention_days: u32) -> Self {
Self::build(Some(retention_days))
}
fn build(daemon_id: &str, history_days: Option<u32>) -> Self {
fn build(history_days: Option<u32>) -> Self {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let worktree_root = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf());
let mut manager = SessionManager::new(
daemon_id.to_string(),
tmux.tmux.clone(),
Arc::new(tmux.tmux.clone()),
sidecar,
worktree_mgr,
state_dir.path().join("fifos"),
+143 -183
View File
@@ -1,7 +1,4 @@
//! Integration tests for the daemon's local HTTP API + broker WS (NAP-243).
//!
//! The legacy `/sessions/:id/stream` endpoint is covered by `tests/attach.rs`;
//! this file exercises the new `/api/*` surface and the same-host broker WS.
//! Integration tests for the daemon's HTTP + WS API (NAP-243).
mod common;
@@ -11,8 +8,8 @@ use std::time::Duration;
use common::DaemonFixture;
use futures_util::{SinkExt, StreamExt};
use maestro_daemon::{local_api, BrokerHandle, CatalogMirror, LocalEventBus};
use maestro_protocol::{CreateSessionRequest, DaemonInfo, RegistryEvent, Session};
use maestro_daemon::local_api;
use maestro_protocol::{CreateSessionRequest, Session};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -24,42 +21,22 @@ type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
struct Harness {
addr: SocketAddr,
manager: maestro_daemon::SessionManager,
catalog: CatalogMirror,
_shutdown: oneshot::Sender<()>,
_fx: DaemonFixture,
}
async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
let fx = DaemonFixture::new(daemon_id);
async fn spawn(hostname: &str) -> Harness {
let fx = DaemonFixture::new();
let manager = fx.manager.clone();
let catalog = CatalogMirror::new();
let events = LocalEventBus::new();
// NAP-247/NAP-327: tests don't exercise the cross-host broker path; hand the
// routers an isolated BrokerHandle whose receiver is dropped, so any
// resolve_attach / forward_create / forward_kill fails fast with
// NoControlLink (the handle starts disconnected).
let (broker, _broker_rx) = BrokerHandle::new();
// Built-in presets (claude/opencode/zsh + custom) so web_info advertises a
// known set (#26).
// Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises
// a known set (#26).
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
let state = local_api::state_for(
let router = local_api::router(local_api::state_for(
manager.clone(),
hostname,
daemon_url,
"/home/test",
presets,
catalog.clone(),
events,
broker.clone(),
);
let broker_state = local_api::BrokerState {
manager: manager.clone(),
catalog: catalog.clone(),
broker,
self_daemon_id: std::sync::Arc::new(daemon_id.into()),
self_perimeter_id: None,
};
let router = local_api::router(state).merge(local_api::broker_router(broker_state));
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
@@ -75,7 +52,6 @@ async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
Harness {
addr,
manager,
catalog,
_shutdown: tx,
_fx: fx,
}
@@ -85,100 +61,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(),
home_dir: String::new(),
},
})
.await;
let list: Vec<DaemonInfo> = reqwest::get(url(h.addr, "/api/daemons"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(list.len(), 2);
let ids: Vec<&str> = list.iter().map(|d| d.daemon_id.as_str()).collect();
assert!(ids.contains(&"laptop"));
assert!(ids.contains(&"konishi"));
}
#[tokio::test]
async fn list_sessions_unions_own_and_peer() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
// Create one local session.
let own = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
// Inject a peer session via the mirror.
let mut peer = own.clone();
peer.id = Uuid::new_v4();
peer.daemon_id = "konishi".into();
h.catalog
.apply(RegistryEvent::SessionAdded {
session: peer.clone(),
})
.await;
let list: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
.unwrap()
.json()
.await
.unwrap();
let ids: Vec<Uuid> = list.iter().map(|s| s.id).collect();
assert!(ids.contains(&own.id), "own session missing: {ids:?}");
assert!(ids.contains(&peer.id), "peer session missing: {ids:?}");
}
#[tokio::test]
async fn list_sessions_starts_empty_and_reflects_creates() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let h = spawn("naps-laptop").await;
let empty: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
@@ -189,8 +92,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
assert!(empty.is_empty());
let req = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: h.manager.daemon_id().into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
@@ -205,7 +107,6 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
.json()
.await
.unwrap();
assert_eq!(created.daemon_id, "laptop");
let after: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
@@ -218,37 +119,11 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
}
#[tokio::test]
async fn post_to_peer_daemon_id_brokers_and_503s_without_registry_link() {
// Cross-daemon create now brokers to the owner over the registry control
// link (NAP-327). This harness has no registry link (the broker starts
// disconnected), so the daemon fails fast with a retryable 503 instead of
// the old 501 — proving it attempts to broker rather than refusing.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let req = CreateSessionRequest {
daemon_id: "konishi".into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let resp = reqwest::Client::new()
.post(url(h.addr, "/api/sessions"))
.json(&req)
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "no_registry_link");
}
#[tokio::test]
async fn web_info_advertises_spawn_presets() {
// #26: the connected daemon's presets ride the web_info payload so the
// spawn form needs no extra round-trip. Built-ins + the implicit custom.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/web/info"))
async fn info_advertises_spawn_presets() {
// #26: the daemon's presets ride the info payload so the spawn form needs
// no extra round-trip. Built-ins + the implicit custom.
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
@@ -264,11 +139,11 @@ async fn web_info_advertises_spawn_presets() {
}
#[tokio::test]
async fn recent_dirs_self_returns_empty_without_history() {
// #26: the self-path reads this daemon's spawn history. The test fixture
// wires no history, so the list is empty but the endpoint is well-formed.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/daemons/laptop/recent-dirs"))
async fn recent_dirs_returns_empty_without_history() {
// #26: reads this daemon's spawn history. The test fixture wires no
// history, so the list is empty but the endpoint is well-formed.
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/recent-dirs"))
.await
.unwrap()
.json()
@@ -279,15 +154,15 @@ async fn recent_dirs_self_returns_empty_without_history() {
}
#[tokio::test]
async fn gitroot_self_probes_path() {
// #26: the self-path resolves the query against the daemon home dir and
// walks up for a `.git`. A `/tmp`-rooted temp dir is not in a repo → null.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
async fn gitroot_probes_path() {
// #26: resolves the query against the daemon home dir and walks up for a
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
let h = spawn("naps-laptop").await;
let tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url(
h.addr,
&format!(
"/api/daemons/laptop/gitroot?path={}",
"/api/gitroot?path={}",
urlencoding::encode(&tmp.path().to_string_lossy())
),
))
@@ -299,22 +174,9 @@ async fn gitroot_self_probes_path() {
assert!(resp.get("root").is_none() || resp["root"].is_null());
}
#[tokio::test]
async fn recent_dirs_for_peer_503s_without_registry_link() {
// #26: a peer target brokers over the (absent) registry link, failing fast
// with a retryable 503 — same shape as the cross-daemon create path.
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp = reqwest::get(url(h.addr, "/api/daemons/konishi/recent-dirs"))
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], "no_registry_link");
}
#[tokio::test]
async fn delete_unknown_session_returns_404() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
.send()
@@ -325,11 +187,10 @@ async fn delete_unknown_session_returns_404() {
#[tokio::test]
async fn delete_owned_session_kills_it() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -348,12 +209,11 @@ async fn delete_owned_session_kills_it() {
}
#[tokio::test]
async fn broker_ws_attaches_to_local_session() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
async fn attach_ws_streams_pty_bytes() {
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -367,7 +227,7 @@ async fn broker_ws_attaches_to_local_session() {
let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap();
// Provoke output. The bash process should echo this back via the PTY.
ws.send(WsMessage::Binary(b"echo hello-broker\n".to_vec()))
ws.send(WsMessage::Binary(b"echo hello-attach\n".to_vec()))
.await
.unwrap();
@@ -375,7 +235,7 @@ async fn broker_ws_attaches_to_local_session() {
loop {
match ws.next().await {
Some(Ok(WsMessage::Binary(bytes))) => {
if String::from_utf8_lossy(&bytes).contains("hello-broker") {
if String::from_utf8_lossy(&bytes).contains("hello-attach") {
return true;
}
}
@@ -386,12 +246,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
@@ -402,3 +262,103 @@ 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).await.expect("kill");
// Activity ticks may interleave; the removal is what we are waiting for.
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
assert!(
tokio::time::Instant::now() < deadline,
"timed out waiting for SessionRemoved"
);
if let maestro_protocol::CatalogEvent::SessionRemoved { id } = next_event(&mut ws).await {
assert_eq!(id, created.id);
break;
}
}
}
/// Each subscriber has its own lag domain now that the publisher task is gone:
/// two clients connected at different times both get their own Snapshot.
#[tokio::test]
async fn every_events_subscriber_gets_its_own_snapshot() {
let h = spawn("konishi").await;
let cwd = tempfile::TempDir::new().unwrap();
let (mut first, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap();
let _ = next_event(&mut first).await;
h.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let (mut second, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap();
match next_event(&mut second).await {
maestro_protocol::CatalogEvent::Snapshot { sessions } => {
assert_eq!(
sessions.len(),
1,
"a late subscriber must see current state"
);
}
other => panic!("expected Snapshot, got {other:?}"),
}
}
async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
loop {
match tokio::time::timeout(Duration::from_secs(5), ws.next())
.await
.expect("events ws timed out")
{
Some(Ok(WsMessage::Text(t))) => {
return serde_json::from_str(&t).expect("decode catalog event")
}
Some(Ok(_)) => continue,
other => panic!("unexpected events frame: {other:?}"),
}
}
}
@@ -1,214 +0,0 @@
//! End-to-end tests for the daemon's outbound registry client.
//!
//! Uses the `maestro-registry` crate as the real registry under test. Verifies
//! the daemon connects, sends Hello + snapshot, forwards diffs, replies to
//! inbound CreateSession/KillSession, and reconnects after a registry kill.
mod common;
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use maestro_daemon::RegistryClient;
use maestro_protocol::CreateSessionRequest;
use maestro_registry::{router, Registry};
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
const TOKEN: &str = "shared-registry-token";
struct RegistryHarness {
url: String,
state: Registry,
shutdown: Option<oneshot::Sender<()>>,
}
async fn spawn_registry() -> RegistryHarness {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let state = Registry::new();
let app = router(state.clone(), TOKEN.to_string());
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
RegistryHarness {
url: format!("ws://{addr}/daemon"),
state,
shutdown: Some(shutdown_tx),
}
}
#[tokio::test]
async fn daemon_registers_and_streams_snapshot() {
let reg = spawn_registry().await;
let fx = DaemonFixture::new("laptop");
let client = RegistryClient::new(
reg.url.clone(),
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
tokio::spawn(async move { client.run().await });
// wait for the Hello + snapshot to land
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move {
let daemons = r.daemons().await;
daemons.len() == 1 && daemons[0].daemon_id == "laptop"
}
})
.await;
// create a session via the manager — should trigger a SessionAdded diff
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let state = reg.state.clone();
let want_id = session.id;
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move {
let s = r.sessions().await;
s.len() == 1 && s[0].id == want_id
}
})
.await;
let _ = fx.manager.kill(session.id).await;
}
#[tokio::test]
async fn daemon_reconnects_after_registry_drop() {
// first registry: accept connection, then drop
let mut reg1 = spawn_registry().await;
let url = reg1.url.clone();
let fx = DaemonFixture::new("laptop");
let client = RegistryClient::new(
url.clone(),
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
tokio::spawn(async move { client.run().await });
// wait for daemon to register on the first registry
let state1 = reg1.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state1.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
// drop registry1; daemon should reconnect once we bring another up on the
// same port. Easier: bring up a second registry on a *different* port
// requires changing url, which RegistryClient doesn't support — instead,
// just verify the daemon's task survives the drop and the original
// registry's state becomes empty (the daemon connection died).
let _ = reg1.shutdown.take().unwrap().send(());
// give some time for the daemon to notice
tokio::time::sleep(Duration::from_millis(500)).await;
// RegistryClient::run loops forever, so we can't easily assert
// reconnection without standing up the registry on the same port again.
// The hard part — graceful failure of the inner connection — is what we're
// really testing: the daemon must not panic.
// (A full reconnect-against-fresh-server test belongs in the e2e crate
// NAP-217.)
let _ = fx;
}
#[tokio::test]
async fn registry_create_session_round_trips() {
let reg = spawn_registry().await;
let fx = DaemonFixture::new("laptop");
let client = RegistryClient::new(
reg.url.clone(),
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
tokio::spawn(async move { client.run().await });
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let cwd = TempDir::new().unwrap();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let result = reg
.state
.create_session("laptop", request)
.await
.expect("transport ok");
let session = result.expect("rpc ok");
assert_eq!(session.command, "sleep 30");
// the registry should also see the SessionAdded diff in its catalog
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move { r.sessions().await.len() == 1 }
})
.await;
// and KillSession should round-trip too
let kill = reg
.state
.kill_session("laptop", session.id)
.await
.expect("transport ok");
kill.expect("rpc ok");
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move { r.sessions().await.is_empty() }
})
.await;
}
@@ -0,0 +1,398 @@
//! The session manager's decisions, driven through [`FakePanes`].
//!
//! These are the branches that used to need a forked tmux server to reach:
//! spawn rollback ordering, liveness diffing, and activity change-detection.
//! Nothing here spawns a process, so they run everywhere and in milliseconds.
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use maestro_daemon::panes::{Call, FakePanes, Op};
use maestro_daemon::session::{SessionError, SessionManager};
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::tmux::PaneInfo;
use maestro_daemon::worktree::WorktreeManager;
use maestro_protocol::{ActivityState, CatalogEvent, CreateSessionRequest, SessionStatus};
use tempfile::TempDir;
use tokio::sync::broadcast::error::TryRecvError;
use uuid::Uuid;
struct Fx {
manager: SessionManager,
fake: Arc<FakePanes>,
tmp: TempDir,
}
impl Fx {
fn new() -> Self {
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
}
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
let tmp = TempDir::new().expect("tempdir");
let fake = Arc::new(FakePanes::new());
let fifo_dir = tmp.path().join("fifos");
std::fs::create_dir_all(&fifo_dir).expect("fifo dir");
let manager = SessionManager::new(
fake.clone(),
SidecarStore::new(pick(tmp.path())),
WorktreeManager::new(tmp.path().join("worktrees")),
fifo_dir,
);
Self { manager, fake, tmp }
}
fn request(&self, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
cwd: self.tmp.path().to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
}
}
}
/// Poll until `id` reaches `want`.
async fn wait_for_status(manager: &SessionManager, id: uuid::Uuid, want: SessionStatus) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
if manager.status(id).await == Some(want) {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("timed out waiting for {id} to become {want:?}");
}
/// NAP-260 rollback, asserted as an *ordering* rather than an end state.
///
/// Replaces a test that forced the sidecar failure with `chmod 0o500` and then
/// returned early when it detected root — so on CI it asserted nothing. Here
/// the sidecar directory cannot be created because a regular file sits where
/// its parent must be, which no uid can work around.
#[tokio::test]
async fn create_kills_the_tmux_session_when_the_sidecar_write_fails() {
let fx = Fx::with_sidecar_dir(|tmp| {
let blocker = tmp.join("blocker");
std::fs::write(&blocker, b"not a directory").expect("blocker file");
blocker.join("sidecars")
});
let res = fx.manager.create(fx.request("sleep 30")).await;
assert!(
matches!(res, Err(SessionError::Sidecar(_))),
"expected the sidecar write to fail, got {res:?}"
);
let calls = fx.fake.calls();
assert_eq!(
calls.len(),
2,
"expected spawn then rollback kill, got {calls:?}"
);
assert!(matches!(calls[0], Call::NewSessionPiped { .. }));
let Call::NewSessionPiped { id, .. } = calls[0] else {
unreachable!()
};
assert_eq!(
calls[1],
Call::KillSession(id),
"the rollback must kill the session it just spawned"
);
assert!(!fx.fake.is_live(id), "create left a ghost tmux session");
assert!(fx.manager.snapshot().await.is_empty());
}
/// A failed spawn must not roll back anything, least of all kill a session id
/// tmux never accepted.
#[tokio::test]
async fn create_does_not_roll_back_when_the_spawn_itself_fails() {
let fx = Fx::new();
fx.fake.fail(Op::NewSessionPiped, "no server running");
let res = fx.manager.create(fx.request("sleep 30")).await;
assert!(res.is_err(), "expected the spawn to fail, got {res:?}");
assert_eq!(
fx.fake.calls().len(),
1,
"only the failed spawn should have been attempted: {:?}",
fx.fake.calls()
);
assert!(fx.manager.snapshot().await.is_empty());
}
/// A restarted daemon adopts whatever tmux still holds, and must re-pipe each
/// one — without that the adopted session produces no output.
#[tokio::test]
async fn recover_adopts_live_sessions_and_repipes_them() {
let fx = Fx::new();
let orphan = Uuid::new_v4();
fx.fake.add_live(orphan);
let adopted = fx.manager.recover().await.expect("recover");
assert_eq!(adopted, 1);
let snap = fx.manager.snapshot().await;
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].id, orphan);
assert!(
fx.fake.calls().contains(&Call::PipePane(orphan)),
"adopted session was never re-piped: {:?}",
fx.fake.calls()
);
}
/// A session tmux lost is marked `Dead` and kept, so the operator can still see
/// it exited and read what it printed. It is not removed.
#[tokio::test]
async fn liveness_marks_the_lost_session_dead_and_keeps_it() {
let fx = Fx::new();
let keep = fx.manager.create(fx.request("keep")).await.expect("keep");
let lose = fx.manager.create(fx.request("lose")).await.expect("lose");
let mut events = fx.manager.events();
fx.fake.remove_live(lose.id);
fx.manager.start_liveness_watcher(Duration::from_millis(25));
wait_for_status(&fx.manager, lose.id, SessionStatus::Dead).await;
let all = fx.manager.snapshot().await;
assert_eq!(all.len(), 2, "the dead session must stay in the catalog");
let survivor = all.iter().find(|s| s.id == keep.id).expect("keep");
assert_eq!(survivor.status, SessionStatus::Running);
let mut removed = Vec::new();
while let Ok(ev) = events.try_recv() {
if let CatalogEvent::SessionRemoved { id } = ev {
removed.push(id);
}
}
assert!(removed.is_empty(), "nothing should be removed: {removed:?}");
}
/// The transition fires once. Without the Running filter the pass re-announces
/// the same dead session on every tick, forever.
#[tokio::test]
async fn a_dead_session_is_announced_only_once() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let mut events = fx.manager.events();
fx.fake.remove_live(s.id);
fx.manager.start_liveness_watcher(Duration::from_millis(25));
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
let mut dead_updates = 0;
let deadline = tokio::time::Instant::now() + Duration::from_millis(300);
while tokio::time::Instant::now() < deadline {
if let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
if session.status == SessionStatus::Dead {
dead_updates += 1;
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(dead_updates, 1, "expected exactly one Dead announcement");
}
/// Dismissing a dead session releases it — without asking tmux to kill
/// something tmux already lost.
#[tokio::test]
async fn killing_a_dead_session_dismisses_it_without_touching_tmux() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
fx.fake.remove_live(s.id);
fx.manager.start_liveness_watcher(Duration::from_millis(25));
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
fx.manager.kill(s.id).await.expect("dismiss");
assert!(fx.manager.snapshot().await.is_empty());
assert!(
!fx.fake
.calls()
.iter()
.any(|c| matches!(c, Call::KillSession(k) if *k == s.id)),
"kill-session was called on a session tmux had already lost: {:?}",
fx.fake.calls()
);
}
/// A dead session has no pane, so input is dropped rather than surfacing as a
/// tmux failure the client would have to interpret.
#[tokio::test]
async fn a_dead_session_is_read_only() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
fx.fake.remove_live(s.id);
fx.manager.start_liveness_watcher(Duration::from_millis(25));
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
fx.manager
.send_keys(s.id, b"ls\n")
.await
.expect("send_keys");
fx.manager.resize(s.id, 80, 24).await.expect("resize");
assert!(
!fx.fake
.calls()
.iter()
.any(|c| matches!(c, Call::SendKeys { .. } | Call::ResizeWindow { .. })),
"input reached tmux for a dead session: {:?}",
fx.fake.calls()
);
}
/// `CreateSessionRequest.env` was accepted, stored, echoed back to clients, and
/// then dropped: `new_session_piped` had no env parameter at all.
#[tokio::test]
async fn requested_env_reaches_tmux() {
let fx = Fx::new();
let mut req = fx.request("bash");
req.env.insert("FOO".into(), "bar".into());
req.env.insert("EMPTY".into(), String::new());
fx.manager.create(req).await.expect("create");
let want: BTreeMap<String, String> = [
("EMPTY".to_string(), String::new()),
("FOO".to_string(), "bar".to_string()),
]
.into_iter()
.collect();
match fx.fake.calls().first() {
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
other => panic!("expected a spawn call, got {other:?}"),
}
}
#[tokio::test]
async fn an_env_key_tmux_would_misparse_is_rejected() {
let fx = Fx::new();
let mut req = fx.request("bash");
req.env.insert("A=B".into(), "x".into());
let err = fx.manager.create(req).await.expect_err("should reject");
assert_eq!(err.to_error_info().code, "invalid_env");
assert!(
fx.fake.calls().is_empty(),
"nothing should have been spawned: {:?}",
fx.fake.calls()
);
}
/// If `list_sessions` fails the pass has no idea what is alive. Reaping on that
/// basis would kill the whole catalog.
#[tokio::test]
async fn liveness_reaps_nothing_when_tmux_cannot_be_listed() {
let fx = Fx::new();
let alive = fx.manager.create(fx.request("bash")).await.expect("create");
fx.fake.fail(Op::ListSessions, "connection refused");
fx.manager.start_liveness_watcher(Duration::from_millis(25));
// Give the watcher several passes to do the wrong thing.
tokio::time::sleep(Duration::from_millis(200)).await;
let snap = fx.manager.snapshot().await;
assert_eq!(
snap.len(),
1,
"a failed list must not be read as 'everything died'"
);
assert_eq!(snap[0].id, alive.id);
}
#[tokio::test]
async fn activity_pass_emits_updated_only_when_something_changed() {
let fx = Fx::new();
let session = fx.manager.create(fx.request("zsh")).await.expect("create");
let mut events = fx.manager.events();
fx.fake.set_pane_info(
session.id,
PaneInfo {
// A pid nothing can be reading from, so `awaiting_input` is a
// deterministic false rather than a probe of this machine.
pane_pid: i32::MAX,
command: "claude".into(),
tty: "/dev/null".into(),
current_path: "/srv/work".into(),
},
);
fx.manager.start_liveness_watcher(Duration::from_millis(25));
let updated = wait_for_updated(&mut events).await;
assert_eq!(updated.foreground.as_deref(), Some("claude"));
assert_eq!(
updated.current_cwd.as_deref(),
Some(std::path::Path::new("/srv/work"))
);
// Still inside `activity::ACTIVE_WINDOW` (3s) since the spawn, so the
// change here is `foreground`, not the activity state.
assert_eq!(updated.activity, ActivityState::Active);
// Drain anything already queued, then confirm a steady pane stays quiet.
// Stays well inside the 3s window, so no Active -> Idle transition either.
while events.try_recv().is_ok() {}
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(
matches!(events.try_recv(), Err(TryRecvError::Empty)),
"an unchanged pane must not re-emit SessionUpdated every tick"
);
}
async fn wait_for_updated(
events: &mut tokio::sync::broadcast::Receiver<CatalogEvent>,
) -> maestro_protocol::Session {
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
match events.try_recv() {
Ok(CatalogEvent::SessionUpdated { session }) => return session,
Ok(_) => continue,
Err(_) => tokio::time::sleep(Duration::from_millis(10)).await,
}
}
panic!("timed out waiting for SessionUpdated");
}
/// The kill handler used to check `has()` first, which opened a window for the
/// liveness watcher to reap the session in between — turning a 404 into a 502.
/// `kill` reports the miss itself now.
#[tokio::test]
async fn killing_an_unknown_session_is_reported_as_not_found() {
let fx = Fx::new();
let err = fx
.manager
.kill(uuid::Uuid::new_v4())
.await
.expect_err("should fail");
assert_eq!(err.to_error_info().code, "not_found");
}
/// A construction-order mistake must be loud: a silently-ignored history leaves
/// the recent-dirs picker permanently empty.
#[test]
#[should_panic(expected = "before the SessionManager is cloned")]
fn attaching_spawn_history_after_cloning_panics() {
let tmp = TempDir::new().expect("tempdir");
let fake = Arc::new(FakePanes::new());
let manager = SessionManager::new(
fake,
SidecarStore::new(tmp.path().join("sidecars")),
WorktreeManager::new(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,
));
}
+35 -160
View File
@@ -4,22 +4,22 @@ mod common;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use maestro_daemon::SessionEvent;
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus};
use tempfile::TempDir;
#[tokio::test]
async fn create_then_kill_round_trip() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -55,13 +55,12 @@ async fn create_then_kill_round_trip() {
// end against a real tmux + on-disk JSONL log.
#[tokio::test]
async fn create_appends_to_spawn_history() {
let fx = DaemonFixture::new_with_history("laptop", 30);
let fx = DaemonFixture::new_with_history(30);
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -87,11 +86,10 @@ async fn create_appends_to_spawn_history() {
// recents query is empty — the spawn itself still succeeds.
#[tokio::test]
async fn create_without_history_logs_nothing() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
fx.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -104,86 +102,9 @@ async fn create_without_history_logs_nothing() {
assert!(!fx.spawn_history_path().exists());
}
#[tokio::test]
async fn rejects_wrong_daemon_in_request() {
let fx = DaemonFixture::new("laptop");
let cwd = TempDir::new().unwrap();
let err = fx
.manager
.create(CreateSessionRequest {
daemon_id: "dev-vm".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 5".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap_err();
assert!(format!("{err}").contains("targets daemon"), "{err}");
}
/// NAP-260: when the sidecar write fails *after* tmux has spawned the session,
/// `create` must roll back — no ghost tmux session, no in-memory record left
/// behind. We force the failure by making the sidecar directory read-only (the
/// FIFO + tmux steps use other dirs and still succeed).
#[tokio::test]
async fn create_rolls_back_tmux_session_when_sidecar_write_fails() {
use std::os::unix::fs::PermissionsExt;
let fx = DaemonFixture::new("laptop");
let cwd = TempDir::new().unwrap();
let sidecar_dir = fx.sidecar_dir();
std::fs::create_dir_all(&sidecar_dir).unwrap();
std::fs::set_permissions(&sidecar_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
// The failure is injected by making the sidecar dir read-only — but root
// bypasses directory permission bits, so the write would succeed and the
// rollback path never trigger. CI runs as root; detect that with a write
// probe and skip rather than report a spurious failure (the rollback is
// still covered in any non-root environment).
let probe = sidecar_dir.join(".perm_probe");
if std::fs::write(&probe, b"x").is_ok() {
let _ = std::fs::remove_file(&probe);
std::fs::set_permissions(&sidecar_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
eprintln!("skipping: sidecar dir writable despite 0o500 (running as root?)");
return;
}
let res = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await;
// Restore perms so the TempDir can be cleaned up regardless of assertions.
std::fs::set_permissions(&sidecar_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(
matches!(res, Err(maestro_daemon::SessionError::Sidecar(_))),
"expected the sidecar write to fail, got {res:?}"
);
// The session tmux spawned must have been killed — no ghost left running.
let live = fx.tmux.tmux.list_sessions().expect("list tmux sessions");
assert!(
live.is_empty(),
"create left a ghost tmux session behind: {live:?}"
);
// And nothing in the in-memory catalog.
assert!(fx.manager.snapshot().await.is_empty());
}
#[tokio::test]
async fn recover_adopts_existing_tmux_sessions() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
// create a session, then "throw away" the SessionManager (the tmux session
@@ -191,7 +112,6 @@ async fn recover_adopts_existing_tmux_sessions() {
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -206,8 +126,7 @@ async fn recover_adopts_existing_tmux_sessions() {
let worktree_mgr =
maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf());
let revived = maestro_daemon::SessionManager::new(
"laptop".to_string(),
fx.tmux.tmux.clone(),
Arc::new(fx.tmux.tmux.clone()),
sidecar,
worktree_mgr,
fx.fifo_dir(),
@@ -220,49 +139,6 @@ async fn recover_adopts_existing_tmux_sessions() {
assert_eq!(snap[0].command, "sleep 30");
}
/// NAP-264: if the operator renamed the daemon between starts (config edit),
/// `recover()` must re-advertise adopted sessions under the CURRENT daemon_id,
/// not the stale one baked into the sidecar — otherwise nothing routes to them.
#[tokio::test]
async fn recover_overwrites_stale_daemon_id_after_rename() {
let fx = DaemonFixture::new("laptop");
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
// Revive against the same dirs + tmux server but under a NEW daemon_id,
// as if the operator edited `daemon_id` and restarted.
let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir());
let worktree_mgr =
maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf());
let revived = maestro_daemon::SessionManager::new(
"laptop-v2".to_string(),
fx.tmux.tmux.clone(),
sidecar,
worktree_mgr,
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
let snap = revived.snapshot().await;
assert_eq!(snap[0].id, s.id);
assert_eq!(
snap[0].daemon_id, "laptop-v2",
"recovered session kept the stale daemon_id from the sidecar"
);
}
/// Regression for NAP-253: the very first bytes a freshly-spawned command
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
/// separate tmux invocation that ran after `new-session` + an async FIFO
@@ -270,13 +146,12 @@ async fn recover_overwrites_stale_daemon_id_after_rename() {
/// into the FIFO.
#[tokio::test]
async fn create_captures_initial_output_in_replay_buffer() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
// The shell prints the marker before doing anything else and
// then sleeps so the session stays alive. Before NAP-253 this
@@ -318,13 +193,12 @@ async fn create_captures_initial_output_in_replay_buffer() {
/// nothing.
#[tokio::test]
async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -350,13 +224,8 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(worktree_root);
let revived = maestro_daemon::SessionManager::new(
"laptop".to_string(),
tmux,
sidecar,
worktree_mgr,
fifo_dir,
);
let revived =
maestro_daemon::SessionManager::new(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");
@@ -387,13 +256,12 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
#[tokio::test]
async fn pty_broadcast_delivers_bytes_to_subscribers() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -437,14 +305,13 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
}
#[tokio::test]
async fn liveness_watcher_reaps_dead_sessions_and_emits_removed() {
let fx = DaemonFixture::new("laptop");
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(),
@@ -463,41 +330,49 @@ 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).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());
}
#[tokio::test]
async fn liveness_watcher_leaves_running_sessions_alone() {
let fx = DaemonFixture::new("laptop");
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let s = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
+8 -9
View File
@@ -12,10 +12,9 @@ use serde_json::Value;
use tempfile::TempDir;
use uuid::Uuid;
fn sample(id: Uuid, daemon_id: &str) -> Session {
fn sample(id: Uuid) -> Session {
Session {
id,
daemon_id: daemon_id.to_string(),
cwd: PathBuf::from("/tmp/work"),
command: "bash".into(),
env: HashMap::from([("FOO".into(), "bar".into())]),
@@ -36,7 +35,7 @@ fn write_then_read_round_trip() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let s = sample(id, "laptop");
let s = sample(id);
store.write(&s).expect("write");
let loaded = store.read(id).expect("read");
@@ -59,7 +58,7 @@ fn delete_removes_file_and_is_idempotent() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let s = sample(id, "laptop");
let s = sample(id);
store.write(&s).unwrap();
assert!(store.path_for(id).exists());
@@ -76,8 +75,8 @@ fn load_all_recovers_every_sidecar_and_skips_garbage() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let s1 = sample(Uuid::new_v4(), "laptop");
let s2 = sample(Uuid::new_v4(), "dev-vm");
let s1 = sample(Uuid::new_v4());
let s2 = sample(Uuid::new_v4());
store.write(&s1).unwrap();
store.write(&s2).unwrap();
@@ -86,9 +85,9 @@ fn load_all_recovers_every_sidecar_and_skips_garbage() {
std::fs::write(dir.path().join("broken.json"), b"{not json}").unwrap();
let mut loaded = store.load_all().expect("load_all");
loaded.sort_by_key(|s| s.daemon_id.clone());
loaded.sort_by_key(|s| s.id);
let mut expected = vec![s1, s2];
expected.sort_by_key(|s| s.daemon_id.clone());
expected.sort_by_key(|s| s.id);
assert_eq!(loaded, expected);
}
@@ -104,7 +103,7 @@ fn write_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let nested = dir.path().join("a/b/c");
let store = SidecarStore::new(&nested);
let s = sample(Uuid::new_v4(), "laptop");
let s = sample(Uuid::new_v4());
store.write(&s).expect("write");
assert!(nested.exists());
assert!(store.path_for(s.id).exists());
+3 -3
View File
@@ -5,7 +5,7 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Cross-process integration tests: real registry + daemons as subprocesses"
description = "Cross-process integration tests: a real daemon as a subprocess"
[lib]
path = "src/lib.rs"
@@ -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
+31 -141
View File
@@ -1,15 +1,17 @@
//! Thin HTTP+WS client used by tests to drive the registry.
//! Thin test harness over [`maestro_protocol::DaemonClient`].
//!
//! Cookies are managed automatically by `reqwest`'s built-in cookie store.
//! HTTP calls delegate to the shared client so the harness exercises the same
//! request building, timeouts and error decoding the CLI and TUI use. Only the
//! WebSocket helpers are test-specific: they hand back a raw stream, or a
//! handle that accumulates events in the background.
#![allow(clippy::result_large_err)]
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::{CatalogEvent, CreateSessionRequest, DaemonClient, Session};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
@@ -18,137 +20,64 @@ 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(())
}
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?)
Ok(self.daemon.kill_session(id).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 +87,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 +99,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()
}
}
+41 -131
View File
@@ -1,4 +1,4 @@
//! Subprocess wrapper around `maestrod`.
//! Subprocess wrapper around `maestro daemon`.
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
@@ -10,68 +10,51 @@ use tempfile::TempDir;
use crate::build::workspace_target_bin;
use crate::util::{free_port, wait_for_async};
/// A live `maestrod` running in a child process. Kill the child on drop.
/// A live daemon running in a child process. Kill the child on drop.
///
/// `state_dir`, `home`, and `tmux_tmpdir` are kept as `TempDir`s so they
/// outlive the process and get cleaned up automatically.
/// `home` and `tmux_tmpdir` are kept as `TempDir`s so they outlive the process
/// and get cleaned up automatically. State lives under `home` via
/// `XDG_STATE_HOME`.
#[derive(Debug)]
pub struct Daemon {
pub daemon_id: String,
pub bind_addr: SocketAddr,
pub daemon_url: String,
child: Option<Child>,
home: TempDir,
state: TempDir,
tmux: TempDir,
registry_url: String,
token: String,
}
/// Optional knobs for `Daemon::spawn_with` — perimeter advertisement for
/// NAP-247 broker tests.
#[derive(Debug, Default, Clone)]
pub struct DaemonOptions {
pub perimeter_id: Option<String>,
pub private_addr: Option<String>,
}
impl Daemon {
/// Spawn a fresh daemon pointing at `registry_base_url` (the registry's
/// HTTP base, e.g. `http://127.0.0.1:1234`). The daemon derives its WS
/// endpoint internally via `derive_daemon_ws_url`.
pub async fn spawn(daemon_id: &str, registry_base_url: &str, token: &str) -> Self {
Self::spawn_with(
daemon_id,
registry_base_url,
token,
DaemonOptions::default(),
)
.await
/// Spawn a fresh daemon on a free localhost port.
pub async fn spawn() -> Self {
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
}
/// Spawn with NAP-247 options (perimeter advertisement).
pub async fn spawn_with(
daemon_id: &str,
registry_base_url: &str,
token: &str,
opts: DaemonOptions,
) -> Self {
let port = free_port();
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
Self::spawn_with_port_and_opts(daemon_id, registry_base_url, token, bind, opts).await
/// Spawn on a caller-supplied bind address.
pub async fn spawn_at(bind: SocketAddr) -> Self {
let home = TempDir::new().expect("home tmpdir");
let tmux = TempDir::new().expect("tmux tmpdir");
let child = launch(bind, home.path(), tmux.path());
let me = Self {
bind_addr: bind,
child: Some(child),
home,
tmux,
};
me.wait_until_ready().await;
me
}
/// Spawn on a caller-supplied bind address. Used by NAP-247 broker tests
/// where the daemon's advertised `private_addr` must equal the actual
/// bind address so the broker can dial it back.
pub async fn spawn_at(
daemon_id: &str,
registry_base_url: &str,
token: &str,
bind: SocketAddr,
opts: DaemonOptions,
) -> Self {
Self::spawn_with_port_and_opts(daemon_id, registry_base_url, token, bind, opts).await
/// HTTP base URL of this daemon's API.
pub fn http_base(&self) -> String {
format!("http://{}", self.bind_addr)
}
/// 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
@@ -85,70 +68,18 @@ impl Daemon {
/// Re-spawn the daemon on the same port + state. The previous tmux
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
/// its sessions. Restarts inherit the original options via the on-disk
/// config file written at `spawn_with_port_and_opts` time.
/// its sessions.
pub async fn restart(&mut self) {
self.stop();
let child = launch(
&self.daemon_id,
self.bind_addr,
&self.daemon_url,
&self.registry_url,
&self.token,
self.home.path(),
self.state.path(),
self.tmux.path(),
&DaemonOptions::default(),
);
self.child = Some(child);
self.wait_until_attach_ready().await;
self.child = Some(launch(self.bind_addr, self.home.path(), self.tmux.path()));
self.wait_until_ready().await;
}
async fn spawn_with_port_and_opts(
daemon_id: &str,
registry_url: &str,
token: &str,
bind: SocketAddr,
opts: DaemonOptions,
) -> Self {
let home = TempDir::new().expect("home tmpdir");
let state = TempDir::new().expect("state tmpdir");
let tmux = TempDir::new().expect("tmux tmpdir");
let daemon_url = format!("ws://{bind}");
let child = launch(
daemon_id,
bind,
&daemon_url,
registry_url,
token,
home.path(),
state.path(),
tmux.path(),
&opts,
);
let me = Self {
daemon_id: daemon_id.to_string(),
bind_addr: bind,
daemon_url,
child: Some(child),
home,
state,
tmux,
registry_url: registry_url.to_string(),
token: token.to_string(),
};
me.wait_until_attach_ready().await;
me
}
async fn wait_until_attach_ready(&self) {
async fn wait_until_ready(&self) {
let addr = self.bind_addr;
wait_for_async(
"daemon attach port",
Duration::from_secs(5),
|| async move { tokio::net::TcpStream::connect(addr).await.is_ok() },
)
wait_for_async("daemon api port", Duration::from_secs(5), || async move {
tokio::net::TcpStream::connect(addr).await.is_ok()
})
.await;
}
@@ -158,7 +89,7 @@ impl Daemon {
pub fn state_dir(&self) -> PathBuf {
// Matches maestro_daemon::default_state_dir which uses XDG_STATE_HOME.
self.home.path().join(".local/state/maestrod")
self.home.path().join(".local/state/maestro")
}
}
@@ -171,18 +102,7 @@ impl Drop for Daemon {
}
}
#[allow(clippy::too_many_arguments)]
fn launch(
daemon_id: &str,
bind: SocketAddr,
daemon_url: &str,
registry_url: &str,
token: &str,
home: &std::path::Path,
_state: &std::path::Path,
tmux_tmpdir: &std::path::Path,
opts: &DaemonOptions,
) -> Child {
fn launch(bind: SocketAddr, home: &std::path::Path, tmux_tmpdir: &std::path::Path) -> Child {
let bin = workspace_target_bin("maestro", "maestro");
let mut cmd = Command::new(&bin);
cmd.arg("daemon");
@@ -195,21 +115,11 @@ fn launch(
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("XDG_STATE_HOME", home.join(".local/state"))
.env("TMUX_TMPDIR", tmux_tmpdir)
.env("MAESTRO_TOKEN", token)
.env("MAESTRO_DAEMON_ID", daemon_id)
.env("MAESTRO_REGISTRY_URL", registry_url)
.env("MAESTRO_DAEMON_BIND_ADDR", bind.to_string())
.env("MAESTRO_DAEMON_URL", daemon_url)
.env("RUST_LOG", "warn")
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.arg("run");
if let Some(p) = &opts.perimeter_id {
cmd.env("MAESTRO_DAEMON_PERIMETER_ID", p);
}
if let Some(a) = &opts.private_addr {
cmd.env("MAESTRO_DAEMON_PRIVATE_ADDR", a);
}
cmd.spawn()
.unwrap_or_else(|e| panic!("spawn {} failed: {e}", bin.display()))
}
+4 -6
View File
@@ -1,8 +1,8 @@
//! Cross-process integration test harness for Maestro.
//!
//! Each test spawns a real `maestro-registry` and one or more `maestrod`
//! subprocesses, isolates them in temp dirs (`$HOME`, `TMUX_TMPDIR`, state
//! dir), and drives them through their public HTTP/WS APIs.
//! Each test spawns a real `maestro daemon` subprocess, isolates it in temp
//! dirs (`$HOME`, `TMUX_TMPDIR`, state dir), and drives it through its public
//! HTTP/WS API.
//!
//! See `tests/` for the actual scenarios.
@@ -11,10 +11,8 @@
pub mod build;
pub mod client;
pub mod daemon;
pub mod registry;
pub mod util;
pub use build::workspace_target_bin;
pub use client::TestClient;
pub use daemon::{Daemon, DaemonOptions};
pub use registry::Registry;
pub use daemon::Daemon;
-95
View File
@@ -1,95 +0,0 @@
//! Subprocess wrapper around `maestro-registry`.
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use tempfile::TempDir;
use crate::build::workspace_target_bin;
use crate::util::{free_port, wait_for_async};
/// A `maestro-registry` running in a child process. The state directory is
/// kept as a `TempDir` so it survives the `Registry`'s lifetime and gets
/// cleaned up on drop.
#[derive(Debug)]
pub struct Registry {
pub addr: SocketAddr,
pub token: String,
child: Option<Child>,
_home: TempDir,
}
impl Registry {
pub async fn spawn(token: &str) -> Self {
let port = free_port();
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let home = TempDir::new().expect("home tmpdir");
let bin = workspace_target_bin("maestro", "maestro");
let child = Command::new(&bin)
.arg("registry")
.env_clear()
// PATH so tmux etc. resolve. We need a few essentials.
.env(
"PATH",
std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into()),
)
.env("HOME", home.path())
.env("XDG_CONFIG_HOME", home.path().join(".config"))
.env("XDG_STATE_HOME", home.path().join(".local/state"))
.env("MAESTRO_TOKEN", token)
.env("MAESTRO_REGISTRY_BIND_ADDR", addr.to_string())
.env("RUST_LOG", "warn")
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.unwrap_or_else(|e| panic!("spawn {} failed: {e}", bin.display()));
let url = format!("http://{addr}/api/daemons");
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("reqwest client");
wait_for_async("registry to bind", Duration::from_secs(5), || {
let http = http.clone();
let url = url.clone();
async move { http.get(&url).send().await.is_ok() }
})
.await;
Self {
addr,
token: token.to_string(),
child: Some(child),
_home: home,
}
}
pub fn http_base(&self) -> String {
format!("http://{}", self.addr)
}
pub fn ws_base(&self) -> String {
format!("ws://{}", self.addr)
}
/// URL daemons dial.
pub fn daemon_ws_url(&self) -> String {
format!("ws://{}/daemon", self.addr)
}
pub fn state_dir(&self) -> PathBuf {
self._home.path().to_path_buf()
}
}
impl Drop for Registry {
fn drop(&mut self) {
if let Some(mut c) = self.child.take() {
let _ = c.kill();
let _ = c.wait();
}
}
}
+63 -576
View File
@@ -1,9 +1,8 @@
//! Cross-process end-to-end tests for the headless Maestro slice.
//!
//! Each test brings up a real `maestro-registry` plus 1-2 real `maestrod`
//! subprocesses, isolates them in temp dirs, and drives them through the
//! public HTTP+WS surface using the same client APIs a real CLI/web client
//! would.
//! Each test brings up a real daemon subprocess, isolated in temp dirs, and
//! drives it through the public HTTP+WS surface using the same client APIs a
//! real CLI/web client would.
//!
//! These tests require `tmux` and `git` on `$PATH`.
@@ -15,45 +14,19 @@ use std::time::{Duration, Instant};
use futures_util::{SinkExt, StreamExt};
use maestro_e2e::util::wait_for_async;
use maestro_e2e::{Daemon, DaemonOptions, Registry, TestClient};
use maestro_e2e::{Daemon, TestClient};
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
use tempfile::TempDir;
use tokio_tungstenite::tungstenite::Message as WsMessage;
const TOKEN: &str = "e2e-shared-token";
async fn bring_up(daemon_ids: &[&str]) -> (Registry, Vec<Daemon>, TestClient) {
let registry = Registry::spawn(TOKEN).await;
let mut daemons = Vec::with_capacity(daemon_ids.len());
for id in daemon_ids {
let d = Daemon::spawn(id, &registry.http_base(), TOKEN).await;
daemons.push(d);
}
let client = TestClient::new(&registry.http_base(), &registry.ws_base());
client.bootstrap().await.expect("bootstrap");
// Wait until both daemons have connected and announced themselves.
let expected: Vec<String> = daemon_ids.iter().map(|s| s.to_string()).collect();
wait_for_async("daemons to register", Duration::from_secs(10), || {
let client = client.clone();
let expected = expected.clone();
async move {
match client.list_daemons().await {
Ok(ds) => expected
.iter()
.all(|id| ds.iter().any(|d| &d.daemon_id == id)),
Err(_) => false,
}
}
})
.await;
(registry, daemons, client)
async fn bring_up() -> (Daemon, TestClient) {
let daemon = Daemon::spawn().await;
let client = TestClient::new(&daemon.http_base());
(daemon, client)
}
fn create_req(daemon_id: &str, cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
daemon_id: daemon_id.into(),
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
@@ -63,211 +36,39 @@ fn create_req(daemon_id: &str, cwd: &std::path::Path, command: &str) -> CreateSe
}
#[tokio::test(flavor = "multi_thread")]
async fn lists_sessions_from_both_daemons() {
let (_reg, _daemons, client) = bring_up(&["alpha", "beta"]).await;
async fn lists_spawned_sessions() {
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let a = client
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
.spawn_session(&create_req(cwd.path(), "sleep 30"))
.await
.expect("spawn alpha");
.expect("spawn a");
let b = client
.spawn_session(&create_req("beta", cwd.path(), "sleep 30"))
.spawn_session(&create_req(cwd.path(), "sleep 30"))
.await
.expect("spawn beta");
.expect("spawn b");
let sessions = client.list_sessions().await.expect("list");
assert!(sessions
.iter()
.any(|s| s.id == a.id && s.daemon_id == "alpha"));
assert!(sessions
.iter()
.any(|s| s.id == b.id && s.daemon_id == "beta"));
assert!(sessions.iter().any(|s| s.id == a.id));
assert!(sessions.iter().any(|s| s.id == b.id));
client.kill_session(a.id).await.expect("kill a");
client.kill_session(b.id).await.expect("kill b");
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_local_api_mirrors_peer_catalog() {
// NAP-246: a daemon's GET /api/daemons + /api/sessions should reflect
// peer state (not just self), because the registry fans out catalog
// events to every connected daemon.
let (_reg, daemons, client) = bring_up(&["alpha", "beta"]).await;
let cwd = TempDir::new().unwrap();
// Create a session on beta via the registry path.
let b = client
.spawn_session(&create_req("beta", cwd.path(), "sleep 30"))
.await
.expect("spawn beta");
// Now query alpha's local HTTP API and confirm it sees beta + beta's
// session. The mirror is eventually consistent, so poll.
let alpha_addr = daemons[0].bind_addr;
let alpha_base = format!("http://{alpha_addr}");
wait_for_async(
"alpha mirrors beta in /api/daemons",
Duration::from_secs(5),
|| {
let base = alpha_base.clone();
async move {
let resp = match reqwest::get(format!("{base}/api/daemons")).await {
Ok(r) => r,
Err(_) => return false,
};
let list: Vec<maestro_protocol::DaemonInfo> = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
};
list.iter().any(|d| d.daemon_id == "beta")
}
},
)
.await;
wait_for_async(
"alpha mirrors beta's session in /api/sessions",
Duration::from_secs(5),
|| {
let base = alpha_base.clone();
let want = b.id;
async move {
let resp = match reqwest::get(format!("{base}/api/sessions")).await {
Ok(r) => r,
Err(_) => return false,
};
let list: Vec<maestro_protocol::Session> = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
};
list.iter().any(|s| s.id == want && s.daemon_id == "beta")
}
},
)
.await;
// Both self and peer come back.
let list: Vec<maestro_protocol::DaemonInfo> = reqwest::get(format!("{alpha_base}/api/daemons"))
.await
.unwrap()
.json()
.await
.unwrap();
let ids: Vec<&str> = list.iter().map(|d| d.daemon_id.as_str()).collect();
assert!(ids.contains(&"alpha"));
assert!(ids.contains(&"beta"));
// NAP-328: home_dir is advertised end-to-end — for a peer relayed through
// the registry (beta, via Hello → register → catalog mirror), so the web
// spawn form can prefill the working directory.
let beta = list.iter().find(|d| d.daemon_id == "beta").unwrap();
assert!(
!beta.home_dir.is_empty(),
"peer home_dir should propagate through the registry: {beta:?}"
);
client.kill_session(b.id).await.expect("kill b");
}
#[tokio::test(flavor = "multi_thread")]
async fn home_daemon_brokers_cross_daemon_create_and_kill() {
// NAP-327: POST /api/sessions to alpha (home) with daemon_id=beta must
// broker the create to beta over the registry control link and return
// beta's session — and a kill via alpha must reach beta too. This is the
// path maestro-web uses (it only talks to its local daemon).
let (_reg, daemons, registry_client) = bring_up(&["alpha", "beta"]).await;
let cwd = TempDir::new().unwrap();
// A client pointed at alpha's *local* API (not the registry).
let alpha_base = format!("http://{}", daemons[0].bind_addr);
let alpha = TestClient::new(&alpha_base, &alpha_base);
// Create a beta-owned session by asking alpha.
let s = alpha
.spawn_session(&create_req("beta", cwd.path(), "sleep 30"))
.await
.expect("alpha brokers create to beta");
assert_eq!(s.daemon_id, "beta", "session should be owned by beta");
// The registry catalog (and thus every client) sees it on beta.
wait_for_async(
"beta session visible in catalog",
Duration::from_secs(5),
|| {
let client = registry_client.clone();
let want = s.id;
async move {
client
.list_sessions()
.await
.map(|ss| ss.iter().any(|x| x.id == want && x.daemon_id == "beta"))
.unwrap_or(false)
}
},
)
.await;
// alpha's mirror must reflect beta's session before it can broker the kill.
wait_for_async(
"alpha mirrors beta's session",
Duration::from_secs(5),
|| {
let base = alpha_base.clone();
let want = s.id;
async move {
let resp = match reqwest::get(format!("{base}/api/sessions")).await {
Ok(r) => r,
Err(_) => return false,
};
let list: Vec<maestro_protocol::Session> = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
};
list.iter().any(|x| x.id == want)
}
},
)
.await;
// Kill the beta session via alpha — brokered to beta.
alpha
.kill_session(s.id)
.await
.expect("alpha brokers kill to beta");
wait_for_async(
"beta session gone from catalog",
Duration::from_secs(5),
|| {
let client = registry_client.clone();
let gone = s.id;
async move {
client
.list_sessions()
.await
.map(|ss| !ss.iter().any(|x| x.id == gone))
.unwrap_or(false)
}
},
)
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn multi_attach_sees_same_output() {
let (_reg, _daemons, client) = bring_up(&["solo"]).await;
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let s = client
.spawn_session(&create_req("solo", cwd.path(), "bash --noprofile --norc"))
.spawn_session(&create_req(cwd.path(), "bash --noprofile --norc"))
.await
.expect("spawn");
let info = client.connect_info(s.id).await.expect("connect-info");
let mut a = client.attach(&info, s.id).await.expect("attach a");
let mut b = client.attach(&info, s.id).await.expect("attach b");
let mut a = client.attach(s.id).await.expect("attach a");
let mut b = client.attach(s.id).await.expect("attach b");
// Drive input through client A; both attachments should observe it via
// the daemon's PTY broadcast.
@@ -314,13 +115,12 @@ async fn multi_attach_sees_same_output() {
#[tokio::test(flavor = "multi_thread")]
async fn cleanup_on_kill_removes_worktree() {
let (_reg, _daemons, client) = bring_up(&["host"]).await;
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let req = CreateSessionRequest {
daemon_id: "host".into(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -362,52 +162,20 @@ async fn cleanup_on_kill_removes_worktree() {
#[tokio::test(flavor = "multi_thread")]
async fn daemon_restart_recovers_session() {
let (_reg, mut daemons, client) = bring_up(&["restarter"]).await;
let (mut daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let s = client
.spawn_session(&create_req("restarter", cwd.path(), "sleep 300"))
.spawn_session(&create_req(cwd.path(), "sleep 300"))
.await
.expect("spawn");
assert_eq!(s.daemon_id, "restarter");
// Stop the daemon and wait for the registry to notice the disconnect.
// The catalog drops sessions for a daemon that's no longer connected.
daemons[0].stop();
wait_for_async(
"registry drops disconnected daemon",
Duration::from_secs(10),
|| {
let client = client.clone();
async move {
client
.list_daemons()
.await
.map(|ds| ds.iter().all(|d| d.daemon_id != "restarter"))
.unwrap_or(false)
}
},
)
.await;
// Bring the daemon back. It should recover its tmux session and the
// registry catalog should repopulate it.
daemons[0].restart().await;
wait_for_async("restarter re-registers", Duration::from_secs(10), || {
let client = client.clone();
async move {
client
.list_daemons()
.await
.map(|ds| ds.iter().any(|d| d.daemon_id == "restarter"))
.unwrap_or(false)
}
})
.await;
// Restart against the same tmux server + state dir: the daemon must adopt
// the still-running tmux session on boot.
daemon.restart().await;
wait_for_async(
"session reappears in catalog",
"session reappears after restart",
Duration::from_secs(10),
|| {
let client = client.clone();
@@ -428,7 +196,7 @@ async fn daemon_restart_recovers_session() {
#[tokio::test(flavor = "multi_thread")]
async fn concurrent_spawns_each_get_unique_ids() {
let (_reg, _daemons, client) = bring_up(&["parallel"]).await;
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let mut handles = Vec::new();
@@ -437,7 +205,7 @@ async fn concurrent_spawns_each_get_unique_ids() {
let path = cwd.path().to_path_buf();
handles.push(tokio::spawn(async move {
client
.spawn_session(&create_req("parallel", &path, "sleep 30"))
.spawn_session(&create_req(&path, "sleep 30"))
.await
.expect("spawn")
}));
@@ -461,8 +229,7 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
// the daemon's recent-dirs endpoint surfaces on the spawn form's next open
// (#27's recents picker), and gitroot infers the worktree base_repo from the
// same dir. Exercised end to end against real tmux + git.
let (_reg, daemons, client) = bring_up(&["host"]).await;
let daemon_base = format!("http://{}", daemons[0].bind_addr);
let (daemon, client) = bring_up().await;
// A git repo as the working dir, so gitroot has a repo to infer.
let repo = TempDir::new().unwrap();
@@ -471,7 +238,7 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
// Spawn in that dir (the command stands in for a preset tile selection).
let s = client
.spawn_session(&create_req("host", repo.path(), "sleep 30"))
.spawn_session(&create_req(repo.path(), "sleep 30"))
.await
.expect("spawn");
@@ -482,16 +249,11 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
"recent-dirs surfaces the spawned cwd",
Duration::from_secs(5),
|| {
let url = format!("{daemon_base}/api/daemons/host/recent-dirs");
let client = daemon.client();
let want = cwd.clone();
async move {
let resp = match reqwest::get(&url).await {
Ok(r) => r,
Err(_) => return false,
};
let recents: maestro_protocol::RecentDirs = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
let Ok(recents) = client.recent_dirs().await else {
return false;
};
recents
.dirs
@@ -504,15 +266,7 @@ async fn spawn_records_history_and_infers_gitroot_for_form() {
// gitroot infers the worktree base_repo from the same dir (#25/#26): the dir
// is itself the repo toplevel (it holds `.git`).
let gr: maestro_protocol::GitRoot = reqwest::Client::new()
.get(format!("{daemon_base}/api/daemons/host/gitroot"))
.query(&[("path", cwd.as_str())])
.send()
.await
.expect("gitroot request")
.json()
.await
.expect("gitroot json");
let gr = daemon.client().git_root(&cwd).await.expect("gitroot");
assert_eq!(
gr.root.as_deref().map(|p| p.to_string_lossy().to_string()),
Some(cwd.clone()),
@@ -530,15 +284,15 @@ async fn activity_reflects_foreground_and_input_blocking() {
// (the distinction the /proc/<pid>/syscall check buys us). Both report the
// right foreground. Exercised against real tmux.
use maestro_protocol::ActivityState;
let (_reg, _daemons, client) = bring_up(&["host"]).await;
let (_daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let cat = client
.spawn_session(&create_req("host", cwd.path(), "cat"))
.spawn_session(&create_req(cwd.path(), "cat"))
.await
.expect("spawn cat");
let slp = client
.spawn_session(&create_req("host", cwd.path(), "sleep 300"))
.spawn_session(&create_req(cwd.path(), "sleep 300"))
.await
.expect("spawn sleep");
@@ -621,244 +375,14 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn broker_t2_attach_through_home_daemon() {
// NAP-247: two daemons share perimeter "wg-test". alpha's broker resolves
// a session owned by beta, gets a pair JWT + perimeter address from the
// registry, dials beta via T2, and proxies the attach.
let registry = Registry::spawn("e2e-shared-token").await;
let beta_bind = std::net::SocketAddr::from((
std::net::Ipv4Addr::LOCALHOST,
maestro_e2e::util::free_port(),
));
let beta = Daemon::spawn_at(
"beta",
&registry.http_base(),
"e2e-shared-token",
beta_bind,
DaemonOptions {
perimeter_id: Some("wg-test".into()),
private_addr: Some(beta_bind.to_string()),
},
)
.await;
let alpha_bind = std::net::SocketAddr::from((
std::net::Ipv4Addr::LOCALHOST,
maestro_e2e::util::free_port(),
));
let alpha = Daemon::spawn_at(
"alpha",
&registry.http_base(),
"e2e-shared-token",
alpha_bind,
DaemonOptions {
perimeter_id: Some("wg-test".into()),
private_addr: Some(alpha_bind.to_string()),
},
)
.await;
assert_eq!(beta.bind_addr, beta_bind);
assert_eq!(alpha.bind_addr, alpha_bind);
let client = TestClient::new(&registry.http_base(), &registry.ws_base());
client.bootstrap().await.expect("bootstrap");
wait_for_async("both daemons connected", Duration::from_secs(10), || {
let client = client.clone();
async move {
client
.list_daemons()
.await
.map(|ds| {
ds.iter().any(|d| d.daemon_id == "alpha")
&& ds.iter().any(|d| d.daemon_id == "beta")
})
.unwrap_or(false)
}
})
.await;
let cwd = TempDir::new().unwrap();
let session = client
.spawn_session(&create_req("beta", cwd.path(), "bash --noprofile --norc"))
.await
.expect("spawn beta");
// Wait until alpha's catalog mirror sees the peer session — otherwise the
// broker can't locate the owning daemon.
let alpha_base = format!("http://{}", alpha.bind_addr);
let sid = session.id;
wait_for_async(
"alpha mirrors beta's session",
Duration::from_secs(5),
|| {
let base = alpha_base.clone();
async move {
let resp = match reqwest::get(format!("{base}/api/sessions")).await {
Ok(r) => r,
Err(_) => return false,
};
let list: Vec<maestro_protocol::Session> = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
};
list.iter().any(|s| s.id == sid && s.daemon_id == "beta")
}
},
)
.await;
// Attach through alpha's broker.
let broker_url = format!("ws://{}/api/attach/{}", alpha.bind_addr, sid);
let (mut ws, _) = tokio_tungstenite::connect_async(broker_url)
.await
.expect("broker WS handshake");
ws.send(WsMessage::Binary(b"echo MAESTRO_BROKER_E2E\n".to_vec()))
.await
.expect("send keys");
let want = b"MAESTRO_BROKER_E2E";
let mut buf = Vec::new();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
tokio::select! {
msg = ws.next() => match msg {
Some(Ok(WsMessage::Binary(b))) => buf.extend(b),
Some(Ok(_)) => {}
_ => break,
},
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
}
if buf.windows(want.len()).any(|w| w == want) {
break;
}
}
assert!(
buf.windows(want.len()).any(|w| w == want),
"broker WS never echoed our keystrokes: buf len {}",
buf.len()
);
let _ = ws.close(None).await;
client.kill_session(sid).await.expect("kill");
drop(alpha);
drop(beta);
drop(registry);
}
#[tokio::test(flavor = "multi_thread")]
async fn broker_attach_returns_502_on_perimeter_mismatch() {
// NAP-270: the asymmetric-VLAN boundary. alpha and beta are on DIFFERENT
// perimeters, so alpha's broker must refuse T2 (the only implemented
// transport) with a structured 502 rather than dial across perimeters.
let registry = Registry::spawn("e2e-shared-token").await;
let beta_bind = std::net::SocketAddr::from((
std::net::Ipv4Addr::LOCALHOST,
maestro_e2e::util::free_port(),
));
let beta = Daemon::spawn_at(
"beta",
&registry.http_base(),
"e2e-shared-token",
beta_bind,
DaemonOptions {
perimeter_id: Some("wg-beta".into()),
private_addr: Some(beta_bind.to_string()),
},
)
.await;
let alpha_bind = std::net::SocketAddr::from((
std::net::Ipv4Addr::LOCALHOST,
maestro_e2e::util::free_port(),
));
let alpha = Daemon::spawn_at(
"alpha",
&registry.http_base(),
"e2e-shared-token",
alpha_bind,
DaemonOptions {
// Different perimeter from beta → T2 unavailable.
perimeter_id: Some("wg-alpha".into()),
private_addr: Some(alpha_bind.to_string()),
},
)
.await;
let client = TestClient::new(&registry.http_base(), &registry.ws_base());
client.bootstrap().await.expect("bootstrap");
wait_for_async("both daemons connected", Duration::from_secs(10), || {
let client = client.clone();
async move {
client
.list_daemons()
.await
.map(|ds| ds.len() >= 2)
.unwrap_or(false)
}
})
.await;
let cwd = TempDir::new().unwrap();
let session = client
.spawn_session(&create_req("beta", cwd.path(), "sleep 30"))
.await
.expect("spawn beta");
let sid = session.id;
// Wait until alpha's catalog mirror sees beta's session.
let alpha_base = format!("http://{}", alpha.bind_addr);
wait_for_async(
"alpha mirrors beta's session",
Duration::from_secs(5),
|| {
let base = alpha_base.clone();
async move {
let resp = match reqwest::get(format!("{base}/api/sessions")).await {
Ok(r) => r,
Err(_) => return false,
};
let list: Vec<maestro_protocol::Session> = match resp.json().await {
Ok(v) => v,
Err(_) => return false,
};
list.iter().any(|s| s.id == sid)
}
},
)
.await;
// Attach through alpha's broker — must 502 (perimeter mismatch), never
// upgrade and never dial beta.
let broker_url = format!("ws://{}/api/attach/{}", alpha.bind_addr, sid);
let err = tokio_tungstenite::connect_async(broker_url)
.await
.expect_err("cross-perimeter attach must fail the handshake");
match err {
tokio_tungstenite::tungstenite::Error::Http(resp) => {
assert_eq!(
resp.status(),
502,
"expected BAD_GATEWAY for perimeter mismatch, got {}",
resp.status()
);
}
other => panic!("expected an HTTP 502 response, got {other:?}"),
}
client.kill_session(sid).await.expect("kill");
drop(alpha);
drop(beta);
drop(registry);
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_events_ws_streams_snapshot_then_session_added() {
// NAP-248: subscribe to a daemon's WS /api/events, expect an initial
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
// Snapshot, then a SessionAdded after we spawn a session via REST.
let (_reg, daemons, client) = bring_up(&["alpha"]).await;
let (daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let alpha_addr = daemons[0].bind_addr;
let ws_url = format!("ws://{alpha_addr}/api/events");
let ws_url = daemon.client().events_url().unwrap();
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url)
.await
.expect("events WS handshake");
@@ -866,23 +390,22 @@ async fn daemon_events_ws_streams_snapshot_then_session_added() {
// First frame must be a Snapshot (the WS sends it eagerly on connect).
let snapshot = next_event(&mut ws).await;
match &snapshot {
maestro_protocol::RegistryEvent::Snapshot { daemons, sessions } => {
assert!(daemons.iter().any(|d| d.daemon_id == "alpha"));
maestro_protocol::CatalogEvent::Snapshot { sessions } => {
assert!(sessions.is_empty());
}
other => panic!("expected initial Snapshot, got {other:?}"),
}
let session = client
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
.spawn_session(&create_req(cwd.path(), "sleep 30"))
.await
.expect("spawn alpha");
.expect("spawn");
// SessionAdded must show up on the WS within a reasonable window.
let saw_added = tokio::time::timeout(Duration::from_secs(5), async {
loop {
match next_event(&mut ws).await {
maestro_protocol::RegistryEvent::SessionAdded { session: s }
maestro_protocol::CatalogEvent::SessionAdded { session: s }
if s.id == session.id =>
{
return true;
@@ -902,7 +425,7 @@ async fn daemon_events_ws_streams_snapshot_then_session_added() {
}
// ---------------------------------------------------------------------------
// NAP-244 — CLI rewired through the home-daemon broker, with registry fallback
// CLI against the local daemon
// ---------------------------------------------------------------------------
/// Run the real `maestro` binary with an isolated env and capture its output.
@@ -921,18 +444,14 @@ fn run_cli(args: &[&str], envs: &[(&str, &str)]) -> std::process::Output {
}
#[tokio::test(flavor = "multi_thread")]
async fn cli_list_reads_from_home_daemon() {
// Broker path: point the CLI at the daemon's local API and DON'T configure
// a registry. Success proves the catalog read came from the home daemon —
// the registry path would have errored with NoRegistryUrl.
let (_reg, daemons, client) = bring_up(&["alpha"]).await;
let cwd = TempDir::new().unwrap();
let session = client
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
.await
.expect("spawn");
async fn cli_list_reads_from_daemon() {
// The subject is the shipped `maestro` binary — arg parsing, config
// precedence, output. It needs a daemon to talk to, not tmux, so this runs
// against an in-memory one.
let mock = maestro_testing::MockDaemon::start().await;
let id = uuid::Uuid::new_v4();
mock.add_session(maestro_testing::session(id, "/home/mock/work", "sleep 30"));
let daemon_url = format!("http://{}", daemons[0].bind_addr);
let home = TempDir::new().unwrap();
let cfg_path = home.path().join("config.toml");
let out = run_cli(
@@ -940,73 +459,41 @@ async fn cli_list_reads_from_home_daemon() {
&[
("HOME", home.path().to_str().unwrap()),
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
("MAESTRO_HOME_DAEMON_URL", &daemon_url),
("MAESTRO_DAEMON_URL", &mock.http_base()),
],
);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "list failed: {stderr}\n{stdout}");
assert!(
stdout.contains(&session.id.to_string()),
"session not listed via home daemon: {stdout}"
stdout.contains(&id.to_string()),
"session not listed: {stdout}"
);
assert!(
!stderr.contains("falling back"),
"unexpected registry fallback: {stderr}"
);
client.kill_session(session.id).await.ok();
}
#[tokio::test(flavor = "multi_thread")]
async fn cli_list_falls_back_to_registry_when_daemon_down() {
// Compat path: home daemon URL points at a dead port (connection refused).
// The CLI must warn and fall back to the configured registry, still
// returning the session.
let (reg, _daemons, client) = bring_up(&["alpha"]).await;
let cwd = TempDir::new().unwrap();
let session = client
.spawn_session(&create_req("alpha", cwd.path(), "sleep 30"))
.await
.expect("spawn");
async fn cli_list_fails_when_daemon_is_down() {
let dead_daemon = format!("http://127.0.0.1:{}", maestro_e2e::util::free_port());
let registry_url = reg.http_base();
let home = TempDir::new().unwrap();
let cfg_path = home.path().join("config.toml");
let out = run_cli(
&["list", "--json"],
&["list"],
&[
("HOME", home.path().to_str().unwrap()),
("MAESTRO_CONFIG", cfg_path.to_str().unwrap()),
("MAESTRO_HOME_DAEMON_URL", &dead_daemon),
("MAESTRO_REGISTRY_URL", &registry_url),
("MAESTRO_DAEMON_URL", &dead_daemon),
],
);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "list failed: {stderr}\n{stdout}");
assert!(
stdout.contains(&session.id.to_string()),
"session not listed via registry fallback: {stdout}"
);
assert!(
stderr.contains("falling back"),
"expected a fallback warning on stderr: {stderr}"
);
client.kill_session(session.id).await.ok();
assert!(!out.status.success(), "expected a failure with no daemon");
}
async fn next_event<S>(ws: &mut S) -> maestro_protocol::RegistryEvent
async fn next_event<S>(ws: &mut S) -> maestro_protocol::CatalogEvent
where
S: StreamExt<Item = Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
{
loop {
match ws.next().await {
Some(Ok(WsMessage::Text(t))) => {
return serde_json::from_str(&t).expect("RegistryEvent")
}
Some(Ok(WsMessage::Text(t))) => return serde_json::from_str(&t).expect("CatalogEvent"),
Some(Ok(_)) => continue,
other => panic!("WS closed waiting for event: {other:?}"),
}
-166
View File
@@ -1,166 +0,0 @@
//! TUI integration tests (NAP-282).
//!
//! Exercises `CatalogStore` and the per-session `SessionTask` against a real
//! registry + daemon subprocess pair. We don't spin the full ratatui frontend
//! here — those interactions are best tested with `TestBackend` unit tests in
//! `crates/maestro-tui/src/*.rs`. This file fills the gap between those
//! and the headless e2e suite by verifying that the TUI's wire layer plays
//! nicely with a real `maestrod`.
//!
//! Requires `tmux` and `git` on `$PATH`.
#![allow(clippy::result_large_err)]
use std::path::PathBuf;
use std::time::{Duration, Instant};
use maestro_e2e::{Daemon, Registry};
use maestro_protocol::CreateSessionRequest;
use maestro_tui::catalog::CatalogStore;
const TOKEN: &str = "tui-integration-token";
async fn bring_up() -> (Registry, Daemon) {
let registry = Registry::spawn(TOKEN).await;
let daemon = Daemon::spawn("konishi", &registry.http_base(), TOKEN).await;
(registry, daemon)
}
fn http_base(daemon: &Daemon) -> String {
format!("http://{}", daemon.bind_addr)
}
fn ws_base(daemon: &Daemon) -> String {
format!("ws://{}", daemon.bind_addr)
}
async fn wait_until<F, Fut>(label: &str, deadline: Duration, mut check: F)
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = bool>,
{
let start = Instant::now();
loop {
if check().await {
return;
}
if start.elapsed() >= deadline {
panic!("timeout waiting for {label}");
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
#[tokio::test(flavor = "multi_thread")]
async fn catalog_bootstrap_against_real_daemon() {
let (_registry, daemon) = bring_up().await;
let ws_url = format!("{}/api/events", ws_base(&daemon));
let store = CatalogStore::default();
let bg = store.clone();
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
.await
.expect("initial snapshot");
let snap = store.snapshot();
assert_eq!(snap.daemons.len(), 1, "self-daemon snapshot");
assert_eq!(snap.daemons[0].daemon_id, "konishi");
assert!(snap.sessions.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn post_session_propagates_through_events_ws() {
let (_registry, daemon) = bring_up().await;
let ws_url = format!("{}/api/events", ws_base(&daemon));
let http_base = http_base(&daemon);
let store = CatalogStore::default();
let bg = store.clone();
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
.await
.expect("initial snapshot");
let cwd = PathBuf::from(daemon.home());
let req = CreateSessionRequest {
daemon_id: "konishi".into(),
cwd,
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
};
let res = reqwest::Client::new()
.post(format!("{http_base}/api/sessions"))
.json(&req)
.send()
.await
.expect("post sessions");
assert!(res.status().is_success(), "POST /api/sessions: {res:?}");
wait_until(
"session to surface in catalog",
Duration::from_secs(2),
|| async { store.snapshot().sessions.len() == 1 },
)
.await;
let snap = store.snapshot();
assert_eq!(snap.sessions.len(), 1);
assert_eq!(snap.sessions[0].daemon_id, "konishi");
assert_eq!(snap.sessions[0].command, "bash");
}
#[tokio::test(flavor = "multi_thread")]
async fn delete_session_propagates_removal() {
let (_registry, daemon) = bring_up().await;
let ws_url = format!("{}/api/events", ws_base(&daemon));
let http_base = http_base(&daemon);
let store = CatalogStore::default();
let bg = store.clone();
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
.await
.expect("initial snapshot");
let cwd = PathBuf::from(daemon.home());
let req = CreateSessionRequest {
daemon_id: "konishi".into(),
cwd,
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
};
let client = reqwest::Client::new();
let res = client
.post(format!("{http_base}/api/sessions"))
.json(&req)
.send()
.await
.unwrap();
assert!(res.status().is_success());
wait_until("creation", Duration::from_secs(2), || async {
store.snapshot().sessions.len() == 1
})
.await;
let sid = store.snapshot().sessions[0].id;
let res = client
.delete(format!("{http_base}/api/sessions/{sid}"))
.send()
.await
.unwrap();
assert!(res.status().is_success(), "DELETE /api/sessions: {res:?}");
wait_until("removal", Duration::from_secs(2), || async {
store.snapshot().sessions.is_empty()
})
.await;
}
+1 -3
View File
@@ -44,9 +44,7 @@ impl SystemctlAction {
/// Build the `cargo install` argument vector.
///
/// `features`, when `Some`, restricts the build to an explicit feature set via
/// `--no-default-features --features <list>`. The installer uses this to skip
/// the `registry` feature of the umbrella `maestro` crate: a host runs the CLI
/// + daemon (+ TUI); the registry is a separate, centrally-deployed service.
/// `--no-default-features --features <list>`.
pub fn cargo_install_args(
source: &CargoSource,
crate_name: &str,
+17 -122
View File
@@ -1,6 +1,5 @@
//! `maestro-install` — interactive wizard that installs the unified `maestro`
//! binary via `cargo install`, optionally drops a systemd user unit, and
//! optionally kicks off `maestro daemon configure`.
//! binary via `cargo install` and optionally drops a systemd user unit.
//!
//! Intended bootstrap path:
//!
@@ -22,13 +21,9 @@ use maestro_installer::{
};
const DEFAULT_GIT_URL: &str = "https://git.naps.pt/naps62/maestro";
/// Unified `maestro` crate. The installer builds it with the host feature set
/// (CLI + daemon + TUI); the `registry` feature is left out (see
/// `MAESTRO_HOST_FEATURES`).
/// Unified `maestro` crate, built with the full host feature set.
const MAESTRO_CRATE: &str = "maestro";
/// Features installed for a host: CLI + per-host daemon + TUI. The `registry`
/// feature is deliberately omitted — the registry is a separate, centrally-
/// deployed service, not something a host install should compile or run.
/// Features installed for a host: CLI + per-host daemon + TUI.
const MAESTRO_HOST_FEATURES: &str = "cli,daemon,tui";
const WEB_CRATE: &str = "maestro-web";
const SYSTEMD_PROBE: &str = "/run/systemd/system";
@@ -57,7 +52,7 @@ struct Args {
#[arg(long)]
no_locked: bool,
/// Don't prompt — accept all defaults (install + systemd + configure).
/// Don't prompt — accept all defaults (install + systemd).
#[arg(long, short)]
yes: bool,
@@ -65,10 +60,6 @@ struct Args {
#[arg(long)]
no_systemd: bool,
/// Skip the `maestro daemon configure` step.
#[arg(long)]
no_configure: bool,
/// Override the ExecStart path written to the systemd unit.
/// Default: `~/.cargo/bin/maestro` (invoked as `maestro daemon`).
#[arg(long, value_name = "PATH")]
@@ -107,51 +98,25 @@ fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
// 1. Install the unified binary (NAP-245), minus the registry feature.
// 1. Install the unified binary (NAP-245).
let locked = !args.no_locked;
install_crate(&source, MAESTRO_CRATE, locked, Some(MAESTRO_HOST_FEATURES))?;
// 2. Offer to run `maestro daemon configure` now (must happen before systemd
// start, since the daemon refuses to run without registry_url / token).
// Re-runs of the wizard auto-skip when the unified config already has a
// [daemon] section; that's what makes "re-run to update" pleasant. In
// non-interactive mode (--yes / no TTY) we also skip — `maestro daemon
// configure` would prompt for required fields and we don't want to
// surprise scripts.
let configured = if let Some(path) = maestro_config_path() {
if daemon_section_present(&path) {
eprintln!(
"\n[daemon] section already present in {} — skipping configure step.",
path.display()
);
eprintln!("(run `maestro daemon configure --force` to rewrite it.)");
true
} else {
run_configure_step(&args, interactive)?
}
} else {
run_configure_step(&args, interactive)?
};
// 3. systemd integration.
// 2. systemd integration.
let systemd_detected = is_systemd_host(Path::new(SYSTEMD_PROBE));
if args.no_systemd {
eprintln!("\nSkipping systemd step (--no-systemd).");
} else if !systemd_detected {
eprintln!("\nNo systemd detected ({SYSTEMD_PROBE} missing). Skipping unit install.");
} else {
handle_systemd(&args, interactive, configured)?;
handle_systemd(&args, interactive)?;
}
// 4. Optional: maestro-web (NAP-249). Re-runs auto-detect an existing
// 3. Optional: maestro-web (NAP-249). Re-runs auto-detect an existing
// install and refresh rather than re-prompting, matching the daemon flow.
let web_installed = handle_web(&args, interactive, systemd_detected)?;
print_summary(
configured,
systemd_detected && !args.no_systemd,
web_installed,
);
print_summary(systemd_detected && !args.no_systemd, web_installed);
Ok(())
}
@@ -170,12 +135,9 @@ fn print_banner(source: &CargoSource) {
eprintln!("maestro installer");
eprintln!();
eprintln!("This wizard will:");
eprintln!(
" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI; registry runs separately)"
);
eprintln!(" 2. (optional) run `maestro daemon configure` to bootstrap the config file");
eprintln!(" 3. (optional) install a systemd user unit and enable it");
eprintln!(" 4. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
eprintln!(" 1. cargo install `{MAESTRO_CRATE}` (CLI + daemon + TUI)");
eprintln!(" 2. (optional) install a systemd user unit and enable it");
eprintln!(" 3. (optional) install `{WEB_CRATE}` + its systemd unit (browser UI)");
eprintln!();
match source {
CargoSource::Git { url, branch } => {
@@ -205,65 +167,7 @@ fn install_crate(
Ok(())
}
fn maestro_config_path() -> Option<PathBuf> {
Some(dirs::config_dir()?.join("maestro").join("config.toml"))
}
/// True iff the unified file exists and already has a `[daemon]` section.
/// Used to skip the configure prompt on re-runs without forcing the user
/// to manually delete a co-located `[registry]` / `[cli]` section first.
fn daemon_section_present(path: &Path) -> bool {
if !path.exists() {
return false;
}
let Ok(body) = std::fs::read_to_string(path) else {
return false;
};
body.lines().any(|l| l.trim_start().starts_with("[daemon]"))
}
fn run_configure_step(args: &Args, interactive: bool) -> Result<bool, Box<dyn std::error::Error>> {
let want = interactive
&& !args.no_configure
&& confirm(
"Run `maestro daemon configure` now (writes ~/.config/maestro/config.toml)?",
true,
)?;
if want {
run_configure_passthrough()
} else {
Ok(false)
}
}
fn run_configure_passthrough() -> Result<bool, Box<dyn std::error::Error>> {
// Run the freshly-installed `maestro daemon configure` interactively. We
// don't hard-code the path; rely on it being on PATH after `cargo install`.
eprintln!("\n==> maestro daemon configure");
let status = Command::new("maestro")
.args(["daemon", "configure"])
.status();
match status {
Ok(s) if s.success() => Ok(true),
Ok(s) => {
eprintln!("`maestro daemon configure` exited {s}");
Ok(false)
}
Err(e) => {
eprintln!(
"couldn't launch `maestro`: {e}. Make sure ~/.cargo/bin is on PATH, \
then run `maestro daemon configure` manually."
);
Ok(false)
}
}
}
fn handle_systemd(
args: &Args,
interactive: bool,
configured: bool,
) -> Result<(), Box<dyn std::error::Error>> {
fn handle_systemd(args: &Args, interactive: bool) -> Result<(), Box<dyn std::error::Error>> {
let unit_path = user_unit_path().ok_or("could not resolve ~/.config/systemd/user/")?;
let exec_start = match &args.exec_start {
Some(p) => p.clone(),
@@ -326,12 +230,6 @@ fn handle_systemd(
} else {
let want = if !interactive {
false
} else if !configured {
eprintln!(
"Note: starting now will fail until you run `maestro daemon configure` \
(no registry_url / token yet)."
);
confirm("Start maestro.service now anyway?", false)?
} else {
confirm("Start maestro.service now?", true)?
};
@@ -421,10 +319,10 @@ fn handle_web(
} else {
eprintln!(
"\nOptional: install the browser UI (`maestro-web`)?\n \
Reverse-proxies a local maestro daemon and serves the SPA at \
Reverse-proxies the local maestro daemon and serves the SPA at \
http://127.0.0.1:8080/ by default.\n \
Edit [web].bind_addr in ~/.config/maestro/config.toml \
to expose it to your perimeter (e.g. WireGuard IP)."
to expose it on your LAN/VPN address."
);
confirm("Install maestro-web?", true)?
};
@@ -549,21 +447,18 @@ fn confirm(prompt: &str, default_yes: bool) -> io::Result<bool> {
})
}
fn print_summary(configured: bool, systemd_enabled: bool, web_installed: bool) {
fn print_summary(systemd_enabled: bool, web_installed: bool) {
eprintln!();
eprintln!("Done.");
eprintln!();
eprintln!("Next steps:");
if !configured {
eprintln!(" • Run `maestro daemon configure` to bootstrap your config file.");
}
if systemd_enabled {
eprintln!(" • Check status: `systemctl --user status maestro`");
eprintln!(" • Logs: `journalctl --user -u maestro -f`");
} else {
eprintln!(" • Start the daemon: `maestro daemon`");
}
eprintln!(" • Set the CLI's registry: `maestro set-registry <URL>`");
eprintln!(" • Open the TUI: `maestro tui`");
if web_installed {
if systemd_enabled {
eprintln!(
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "maestro-names"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Orchestra-flavoured session-handle generator (adjective × instrument) shared across Maestro"
[dependencies]
rand.workspace = true
[dev-dependencies]
rand = { workspace = true, features = ["small_rng"] }
-214
View File
@@ -1,214 +0,0 @@
//! `maestro-names` — the shared session-handle generator.
//!
//! Maestro is a conductor's podium for every shell you care about, so the
//! handles it hands out are drawn from the orchestra pit: a musical adjective
//! (tempo, dynamics, articulation) paired with an instrument or musical form.
//! The result reads like a marking on a score — `allegro-cello`,
//! `crescendo-oboe`, `staccato-viola`.
//!
//! ## Determinism
//!
//! Every pure path takes an **injected** [`rand::Rng`]; nothing here reaches
//! for `thread_rng()` or the clock, so seeded tests are reproducible (a repo
//! invariant). [`generate_thread`] is the one impure convenience wrapper for
//! production callers that just want a name and don't care about the seed.
//!
//! The Rust generator is the source of truth. If the web UI wants an instant
//! client-side reroll it can mirror [`ADJECTIVES`] / [`NOUNS`] verbatim, but
//! the wordlists live here.
use rand::seq::SliceRandom;
use rand::Rng;
/// Musical adjectives — tempo, dynamics, and articulation markings.
pub const ADJECTIVES: &[&str] = &[
"allegro",
"andante",
"adagio",
"presto",
"vivace",
"largo",
"grave",
"lento",
"moderato",
"crescendo",
"staccato",
"legato",
"forte",
"dolce",
"agitato",
"cantabile",
"sostenuto",
"tremolo",
"rubato",
"marcato",
"brillante",
"maestoso",
"grazioso",
"espressivo",
"tranquillo",
"animato",
"spirito",
"sotto",
"vivo",
"feroce",
];
/// Musical nouns — instruments and forms from the concert hall.
pub const NOUNS: &[&str] = &[
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet", "trombone",
"tuba", "horn", "timpani", "harp", "piano", "organ", "lyre", "lute", "fugue", "sonata",
"octave", "chord", "cadence", "prelude", "rondo", "nocturne", "scherzo", "refrain", "coda",
"motif", "overture",
];
/// Generate a single `adjective-noun` handle from an injected RNG.
///
/// ```
/// use rand::SeedableRng;
/// let mut rng = rand::rngs::SmallRng::seed_from_u64(7);
/// let name = maestro_names::generate(&mut rng);
/// assert!(name.contains('-'));
/// ```
pub fn generate<R: Rng + ?Sized>(rng: &mut R) -> String {
// Both lists are compile-time non-empty (asserted in tests), so the
// `choose` calls cannot return `None`.
let adj = ADJECTIVES.choose(rng).expect("ADJECTIVES is non-empty");
let noun = NOUNS.choose(rng).expect("NOUNS is non-empty");
format!("{adj}-{noun}")
}
/// Generate a handle that isn't already taken.
///
/// Draws `adjective-noun` candidates from `rng`; if `is_taken` rejects one, a
/// numeric uniqueness suffix is appended and incremented (`-2`, `-3`, …) until
/// a free handle is found. The bare form is preferred — the suffix only
/// appears on collision, matching the score-marking aesthetic.
///
/// `is_taken` is the single source of "already used"; given the same RNG and
/// the same predicate the output is fully deterministic.
pub fn generate_unique<R, F>(rng: &mut R, is_taken: F) -> String
where
R: Rng + ?Sized,
F: Fn(&str) -> bool,
{
let base = generate(rng);
if !is_taken(&base) {
return base;
}
// Start at 2 so the first duplicate reads `name`, `name-2`, `name-3` …
for suffix in 2u64.. {
let candidate = format!("{base}-{suffix}");
if !is_taken(&candidate) {
return candidate;
}
}
unreachable!("u64 suffix space is effectively inexhaustible")
}
/// Impure convenience wrapper: generate a handle from the thread RNG.
///
/// For production callers that just want a name. Tests and any deterministic
/// path should use [`generate`] / [`generate_unique`] with a seeded RNG.
pub fn generate_thread() -> String {
generate(&mut rand::thread_rng())
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::SmallRng;
use rand::SeedableRng;
use std::collections::HashSet;
fn seeded(seed: u64) -> SmallRng {
SmallRng::seed_from_u64(seed)
}
#[test]
fn wordlists_are_non_empty() {
assert!(!ADJECTIVES.is_empty());
assert!(!NOUNS.is_empty());
}
#[test]
fn wordlists_have_no_duplicates() {
assert_eq!(
ADJECTIVES.iter().collect::<HashSet<_>>().len(),
ADJECTIVES.len(),
"duplicate adjective"
);
assert_eq!(
NOUNS.iter().collect::<HashSet<_>>().len(),
NOUNS.len(),
"duplicate noun"
);
}
#[test]
fn generate_is_adjective_dash_noun() {
let mut rng = seeded(1);
let name = generate(&mut rng);
let (adj, noun) = name.split_once('-').expect("must contain a single dash");
assert!(ADJECTIVES.contains(&adj), "{adj} not an adjective");
assert!(NOUNS.contains(&noun), "{noun} not a noun");
}
#[test]
fn generation_is_deterministic_under_seed() {
// Same seed → identical sequence; this is the core repo invariant.
let mut a = seeded(42);
let mut b = seeded(42);
for _ in 0..50 {
assert_eq!(generate(&mut a), generate(&mut b));
}
}
#[test]
fn different_seeds_diverge() {
// Not a guarantee for a single draw, but across a run the sequences
// must differ — otherwise the RNG isn't actually being threaded.
let mut a = seeded(1);
let mut b = seeded(2);
let seq_a: Vec<_> = (0..30).map(|_| generate(&mut a)).collect();
let seq_b: Vec<_> = (0..30).map(|_| generate(&mut b)).collect();
assert_ne!(seq_a, seq_b);
}
#[test]
fn unique_returns_bare_name_when_free() {
let mut rng = seeded(3);
let name = generate_unique(&mut rng, |_| false);
assert!(
!name.ends_with("-2"),
"no suffix expected when nothing taken"
);
assert!(name.contains('-'));
}
#[test]
fn unique_appends_incrementing_suffix_on_collision() {
// Reserve the bare name and the first two suffixed variants; the
// generator must walk past them to `-4`.
let mut probe = seeded(9);
let base = generate(&mut probe);
let taken: HashSet<String> = [base.clone(), format!("{base}-2"), format!("{base}-3")]
.into_iter()
.collect();
let mut rng = seeded(9);
let name = generate_unique(&mut rng, |n| taken.contains(n));
assert_eq!(name, format!("{base}-4"));
}
#[test]
fn unique_suffix_starts_at_two() {
let mut probe = seeded(11);
let base = generate(&mut probe);
let taken: HashSet<String> = [base.clone()].into_iter().collect();
let mut rng = seeded(11);
let name = generate_unique(&mut rng, |n| taken.contains(n));
assert_eq!(name, format!("{base}-2"));
}
}
+10 -1
View File
@@ -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
+202
View File
@@ -0,0 +1,202 @@
//! Typed client for the daemon's `/api/*` surface.
//!
//! Behind the `client` feature so consumers that only need the wire types and
//! route paths — `maestro-web`, `maestro-config` — don't pull an HTTP stack.
//!
//! This is the one place that knows how to call the daemon: the CLI, the TUI and
//! the end-to-end harness all go through it.
use std::time::Duration;
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Total budget for a single request. Bounds the case where the daemon is
/// TCP-alive but never makes forward progress.
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("http: {0}")]
Http(#[from] reqwest::Error),
/// The daemon answered with a structured [`ApiError`] envelope.
#[error("daemon returned {status} ({}): {}", .info.code, .info.message)]
Api { status: StatusCode, info: ErrorInfo },
/// A non-success response whose body was not an [`ApiError`].
#[error("daemon returned {status}: {body}")]
Status { status: StatusCode, body: String },
#[error("decode: {0}")]
Json(#[from] serde_json::Error),
#[error(transparent)]
Url(#[from] UrlError),
}
impl ClientError {
/// True when the daemon could not be reached at all, rather than reaching it
/// and being refused. Drives "is the daemon running?" messaging.
pub fn is_unreachable(&self) -> bool {
matches!(self, ClientError::Http(e) if e.is_connect())
}
/// The daemon's own message when it sent one, so callers can render the
/// reason rather than a bare status code.
pub fn daemon_message(&self) -> Option<&str> {
match self {
ClientError::Api { info, .. } => Some(&info.message),
_ => None,
}
}
}
/// HTTP client bound to one daemon's base URL.
///
/// The daemon's surface needs no auth — reaching its bind address is the
/// authorization, same trust model as tmux itself.
#[derive(Debug, Clone)]
pub struct DaemonClient {
base: String,
http: reqwest::Client,
}
impl DaemonClient {
/// Client with the standard timeouts.
pub fn new(base: impl Into<String>) -> Self {
Self::with_timeouts(base, DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT)
}
/// Client with explicit timeouts, for callers with a tighter budget than the
/// default — the TUI's startup health probe wants to fail fast rather than
/// hold the terminal for 30 seconds.
pub fn with_timeouts(base: impl Into<String>, connect: Duration, request: Duration) -> Self {
let http = reqwest::Client::builder()
.user_agent(concat!("maestro/", env!("CARGO_PKG_VERSION")))
.connect_timeout(connect)
.timeout(request)
.build()
.expect("build reqwest client");
Self {
base: routes::normalize_base(&base.into()).to_string(),
http,
}
}
pub fn base(&self) -> &str {
&self.base
}
/// `WS /api/events` URL for this daemon.
pub fn events_url(&self) -> Result<String, ClientError> {
Ok(routes::events_url(&self.base)?)
}
/// `WS /api/attach/:id` URL for this daemon.
pub fn attach_url(&self, id: Uuid) -> Result<String, ClientError> {
Ok(routes::attach_url(&self.base, id)?)
}
// -- typed calls --------------------------------------------------------
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
self.get(routes::SESSIONS, &[]).await
}
pub async fn create_session(&self, req: &CreateSessionRequest) -> Result<Session, ClientError> {
self.post(routes::SESSIONS, req).await
}
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
let resp = self
.send(Method::DELETE, &routes::session(id), &[], None::<&()>)
.await?;
check(resp).await?;
Ok(())
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await
}
pub async fn list_dir(&self, path: &str) -> Result<DirListing, ClientError> {
self.get(routes::DIRS, &[(routes::PATH_QUERY, path)]).await
}
pub async fn recent_dirs(&self) -> Result<RecentDirs, ClientError> {
self.get(routes::RECENT_DIRS, &[]).await
}
pub async fn git_root(&self, path: &str) -> Result<GitRoot, ClientError> {
self.get(routes::GITROOT, &[(routes::PATH_QUERY, path)])
.await
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, ClientError> {
let resp = self.send(Method::GET, path, query, None::<&()>).await?;
json(check(resp).await?).await
}
async fn post<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, ClientError> {
let resp = self.send(Method::POST, path, &[], Some(body)).await?;
json(check(resp).await?).await
}
async fn send<B: Serialize>(
&self,
method: Method,
path: &str,
query: &[(&str, &str)],
body: Option<&B>,
) -> Result<Response, ClientError> {
let mut req = self
.http
.request(method, routes::http_url(&self.base, path));
if !query.is_empty() {
req = req.query(query);
}
if let Some(b) = body {
req = req.json(b);
}
Ok(req.send().await?)
}
}
/// Turn a non-success response into the richest error its body supports: the
/// daemon's [`ApiError`] envelope when present, the raw body otherwise.
async fn check(resp: Response) -> Result<Response, ClientError> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let body = resp.text().await.unwrap_or_default();
match serde_json::from_str::<ApiError>(&body) {
Ok(api) => Err(ClientError::Api {
status,
info: api.error,
}),
Err(_) => Err(ClientError::Status { status, body }),
}
}
async fn json<T: DeserializeOwned>(resp: Response) -> Result<T, ClientError> {
// `204 No Content` is a valid success for the calls that return `()`; the
// typed getters never hit it.
Ok(resp.json::<T>().await?)
}
File diff suppressed because it is too large Load Diff
+180
View File
@@ -0,0 +1,180 @@
//! The daemon's `/api/*` surface: paths, and the scheme swap every client
//! needs to reach the WebSocket endpoints.
//!
//! Everything that knows what the daemon's URLs look like lives here.
use uuid::Uuid;
/// Prefix every daemon route shares. `maestro-web` uses it to decide whether a
/// request is proxied to the daemon or served from the SPA bundle.
pub const API_PREFIX: &str = "/api";
/// `GET` list / `POST` spawn.
pub const SESSIONS: &str = "/api/sessions";
/// `GET` daemon identity, version, and spawn presets.
pub const INFO: &str = "/api/info";
/// `GET` one directory's immediate subdirectories. Takes a `path` query param.
pub const DIRS: &str = "/api/dirs";
/// `GET` spawn history.
pub const RECENT_DIRS: &str = "/api/recent-dirs";
/// `GET` the enclosing repository of a path. Takes a `path` query param.
pub const GITROOT: &str = "/api/gitroot";
/// `WS` catalog snapshot + session deltas.
pub const EVENTS: &str = "/api/events";
/// Name of the query parameter [`DIRS`] and [`GITROOT`] both take.
pub const PATH_QUERY: &str = "path";
/// `DELETE /api/sessions/:id`.
pub fn session(id: Uuid) -> String {
format!("{SESSIONS}/{id}")
}
/// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}")
}
/// Axum-style patterns for the two parameterised routes, so the server
/// registers the same paths [`session`] and [`attach`] build.
pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id";
pub const ATTACH: &str = "/api/attach/:id";
}
/// A daemon base URL that could not be turned into a WebSocket base.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UrlError {
#[error("daemon url has no supported scheme (want http/https/ws/wss): {0}")]
UnsupportedScheme(String),
}
/// Strip a trailing slash so `{base}{path}` never doubles up.
pub fn normalize_base(base: &str) -> &str {
base.trim_end_matches('/')
}
/// Join a daemon base URL and one of this module's paths.
pub fn http_url(base: &str, path: &str) -> String {
format!("{}{}", normalize_base(base), path)
}
/// Swap an HTTP base URL for its WebSocket equivalent.
///
/// `http://` becomes `ws://`, `https://` becomes `wss://`, and a base that is
/// already `ws://`/`wss://` passes through. Anything else is an error — a
/// scheme-less base would otherwise produce a URL that fails much later, at
/// connect time, with nothing pointing back at the configuration that caused
/// it.
pub fn ws_base(http_base: &str) -> Result<String, UrlError> {
let base = normalize_base(http_base);
if let Some(rest) = base.strip_prefix("https://") {
Ok(format!("wss://{rest}"))
} else if let Some(rest) = base.strip_prefix("http://") {
Ok(format!("ws://{rest}"))
} else if base.starts_with("wss://") || base.starts_with("ws://") {
Ok(base.to_string())
} else {
Err(UrlError::UnsupportedScheme(http_base.to_string()))
}
}
/// Full `WS /api/events` URL for a daemon reachable at `http_base`.
pub fn events_url(http_base: &str) -> Result<String, UrlError> {
Ok(format!("{}{}", ws_base(http_base)?, EVENTS))
}
/// Full `WS /api/attach/:id` URL for a daemon reachable at `http_base`.
pub fn attach_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
}
#[cfg(test)]
mod tests {
use super::*;
fn id() -> Uuid {
Uuid::parse_str("6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50").unwrap()
}
#[test]
fn ws_base_swaps_http_and_https() {
assert_eq!(
ws_base("http://127.0.0.1:7070").unwrap(),
"ws://127.0.0.1:7070"
);
assert_eq!(
ws_base("https://laptop.lan:7070").unwrap(),
"wss://laptop.lan:7070"
);
}
#[test]
fn ws_base_passes_through_websocket_schemes() {
assert_eq!(ws_base("ws://10.0.0.1:7070").unwrap(), "ws://10.0.0.1:7070");
assert_eq!(
ws_base("wss://10.0.0.1:7070").unwrap(),
"wss://10.0.0.1:7070"
);
}
#[test]
fn ws_base_rejects_an_unknown_scheme() {
assert_eq!(
ws_base("127.0.0.1:7070"),
Err(UrlError::UnsupportedScheme("127.0.0.1:7070".into()))
);
assert!(ws_base("ftp://host:7070").is_err());
}
#[test]
fn trailing_slashes_never_double_up() {
assert_eq!(ws_base("http://host:7070/").unwrap(), "ws://host:7070");
assert_eq!(
http_url("http://host:7070/", SESSIONS),
"http://host:7070/api/sessions"
);
assert_eq!(
events_url("http://host:7070/").unwrap(),
"ws://host:7070/api/events"
);
}
#[test]
fn builds_per_session_paths() {
assert_eq!(
session(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
attach_url("https://laptop.lan:7070", id()).unwrap(),
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
}
/// The server registers `patterns::*` while clients call `session`/`attach`;
/// nothing but this test keeps the two spellings in step.
#[test]
fn patterns_and_builders_agree() {
assert_eq!(
patterns::SESSION.trim_end_matches(":id"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
);
}
#[test]
fn every_route_sits_under_the_api_prefix() {
for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, GITROOT, EVENTS] {
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
}
assert!(attach(id()).starts_with(API_PREFIX));
}
}
-35
View File
@@ -1,35 +0,0 @@
[package]
name = "maestro-registry"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Always-on registry — daemon presence index and session catalog"
[lib]
path = "src/lib.rs"
[dependencies]
axum.workspace = true
chrono.workspace = true
dirs.workspace = true
futures-util.workspace = true
jsonwebtoken.workspace = true
maestro-config.workspace = true
maestro-protocol.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies"] }
tempfile.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "test-util"] }
tokio-tungstenite.workspace = true
-172
View File
@@ -1,172 +0,0 @@
//! Cookie-based auth for the client-facing HTTP/WS surface.
//!
//! On the first request the registry sets an httpOnly cookie whose value is a
//! JWT signed with the shared token (`MAESTRO_TOKEN`, HS256). Subsequent requests
//! must present a cookie whose signature verifies; missing or tampered
//! cookies get a fresh cookie issued (effectively re-bootstrapping a
//! first-time client) and are still allowed through. The cookie is the second
//! factor against anyone routing to the bind address; per spec §Auth the
//! perimeter is the operator's network.
//!
//! Implementation notes:
//!
//! - We reuse `jsonwebtoken` so we don't pull in a cookie/HMAC crate just for
//! this one signature.
//! - The middleware passes the inbound `Cookie` header through to the inner
//! handler unchanged, and adds a `Set-Cookie` response header if the inbound
//! cookie was missing or invalid.
use std::sync::Arc;
use axum::body::Body;
use axum::extract::State;
use axum::http::{header, HeaderValue, Request, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use maestro_protocol::REGISTRY_ISSUER;
use serde::{Deserialize, Serialize};
/// Name of the auth cookie surfaced to web clients. Defined in
/// `maestro-protocol` so the CLI agrees; re-exported here for local callers.
pub use maestro_protocol::{extract_cookie, COOKIE_NAME};
/// 30 days. Cookie is the second factor — long enough not to log users out;
/// short enough to bound the blast radius if the secret rotates.
const COOKIE_TTL_DAYS: i64 = 30;
/// `aud` claim that distinguishes a registry auth cookie from every other
/// registry-signed JWT (pair-attach credentials carry a daemon-id audience).
/// Verifiers bind to it so a token minted for another purpose can't be replayed
/// as a cookie even though both share the signing secret (NAP-266).
const COOKIE_AUDIENCE: &str = "maestro-cookie";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CookieClaims {
/// Issuer — always [`REGISTRY_ISSUER`].
iss: String,
/// Audience — always [`COOKIE_AUDIENCE`].
aud: String,
/// Subject — currently always `"client"` because this is single-user.
sub: String,
exp: i64,
iat: i64,
}
/// Issue a fresh cookie value (a JWT) signed with `secret`.
pub fn issue_cookie(secret: &str) -> String {
let now = Utc::now();
let claims = CookieClaims {
iss: REGISTRY_ISSUER.into(),
aud: COOKIE_AUDIENCE.into(),
sub: "client".into(),
iat: now.timestamp(),
exp: (now + Duration::days(COOKIE_TTL_DAYS)).timestamp(),
};
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, &claims, &key).expect("encode auth cookie")
}
/// Returns true if `value` is a JWT validly signed by `secret` *and* bound to
/// this cookie's issuer + audience — so a pair-attach JWT (same secret,
/// different `aud`) is rejected here (NAP-266).
pub fn verify_cookie(secret: &str, value: &str) -> bool {
let key = DecodingKey::from_secret(secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&[REGISTRY_ISSUER]);
validation.set_audience(&[COOKIE_AUDIENCE]);
decode::<CookieClaims>(value, &key, &validation).is_ok()
}
/// Format the full `Set-Cookie` header value.
fn set_cookie_header(value: &str) -> String {
format!(
"{name}={value}; HttpOnly; SameSite=Strict; Path=/; Max-Age={age}",
name = COOKIE_NAME,
age = COOKIE_TTL_DAYS * 24 * 60 * 60,
)
}
/// Shared state for the auth middleware: just the signing secret.
#[derive(Debug, Clone)]
pub struct AuthState {
pub token: Arc<String>,
}
/// Axum middleware that enforces / bootstraps the client auth cookie.
///
/// - Valid cookie present → pass through, no `Set-Cookie`.
/// - Cookie missing → issue a fresh one and pass through.
/// - Cookie present but tampered/expired → issue a fresh one and pass through.
/// (Spec is intentionally lenient — perimeter is the operator's network.)
pub async fn cookie_middleware(
State(state): State<AuthState>,
request: Request<Body>,
next: Next,
) -> Response {
let token = state.token.clone();
let inbound_cookie = request
.headers()
.get(header::COOKIE)
.and_then(|h| h.to_str().ok())
.and_then(|v| extract_cookie(v, COOKIE_NAME).map(|s| s.to_string()));
let needs_new_cookie = match &inbound_cookie {
Some(value) => !verify_cookie(token.as_str(), value),
None => true,
};
let mut response = next.run(request).await;
if needs_new_cookie {
let fresh = issue_cookie(token.as_str());
match HeaderValue::from_str(&set_cookie_header(&fresh)) {
Ok(hv) => {
response.headers_mut().append(header::SET_COOKIE, hv);
}
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "cookie encode failed").into_response();
}
}
}
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
let token = "shared-secret-aaaa";
let value = issue_cookie(token);
assert!(verify_cookie(token, &value));
assert!(!verify_cookie("different-secret", &value));
}
/// NAP-266: a pair-attach JWT is signed with the same shared secret but is
/// bound to a daemon-id audience, so it must NOT validate as an auth cookie.
#[test]
fn verify_cookie_rejects_pair_attach_jwt() {
let secret = "shared-secret-bbbb";
let pair =
crate::jwt::issue_pair_attach_jwt(secret, "laptop", "konishi", uuid::Uuid::new_v4())
.expect("issue pair jwt");
assert!(
!verify_cookie(secret, &pair),
"a pair-attach JWT must not be accepted as an auth cookie"
);
}
#[test]
fn set_cookie_header_includes_attributes() {
let h = set_cookie_header("abc");
assert!(h.contains("HttpOnly"));
assert!(h.contains("SameSite=Strict"));
assert!(h.contains("Path=/"));
assert!(h.starts_with("maestro_session=abc;"));
}
}
-251
View File
@@ -1,251 +0,0 @@
//! Client-facing HTTP API routes (NAP-215).
//!
//! Mounts under the same router as the daemon-facing `WS /daemon`. The cookie
//! middleware in [`crate::auth`] gates everything except the WS upgrade for
//! `/daemon` (daemons authenticate via the shared token in `Hello`).
use std::sync::Arc;
use std::time::Duration;
use axum::extract::{ws::WebSocketUpgrade, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get};
use axum::{Json, Router};
use maestro_protocol::{ConnectInfo, CreateSessionRequest, DaemonInfo, DirListing, Session};
use serde::Deserialize;
use uuid::Uuid;
use crate::events::events_ws;
use crate::jwt::issue_attach_jwt;
use crate::state::{Registry, RegistryError, RPC_TIMEOUT};
/// Shared state plumbed into client-facing handlers.
#[derive(Debug, Clone)]
pub struct ClientApiState {
pub registry: Registry,
pub token: Arc<String>,
pub rpc_timeout: Duration,
}
/// Mount client-facing routes on the given router. Caller is responsible for
/// applying the cookie middleware (we do that in [`crate::server::router`]).
pub fn routes(state: ClientApiState) -> Router {
// The registry serves no web UI — that's `maestro-web`'s job (see
// connection-orchestration spec §"UI hosting"). Only the JSON/WS API here.
Router::new()
.route("/api/daemons", get(list_daemons))
.route("/api/daemons/:id/dirs", get(list_dir))
.route("/api/sessions", get(list_sessions).post(create_session))
.route("/api/sessions/:id", axum::routing::delete(delete_session))
.route("/api/sessions/:id/connect-info", get(session_connect_info))
.route("/api/events", any(events_ws_handler))
.with_state(state)
}
async fn list_daemons(State(s): State<ClientApiState>) -> Json<Vec<DaemonInfo>> {
let infos: Vec<DaemonInfo> = s
.registry
.daemons()
.await
.into_iter()
.map(Into::into)
.collect();
Json(infos)
}
async fn list_sessions(State(s): State<ClientApiState>) -> Json<Vec<Session>> {
Json(s.registry.sessions().await)
}
/// Query string for `GET /api/daemons/:id/dirs`. `path` is the directory to
/// list; empty/`~`/relative are resolved against the target daemon's home dir.
#[derive(Debug, Deserialize)]
struct DirQuery {
#[serde(default)]
path: String,
}
/// List one directory's immediate subdirectories on `daemon_id`'s host, for the
/// web spawn form's working-directory autocomplete (NAP-329). Drives a `ListDir`
/// RPC to the target daemon, same machinery + error mapping as `create_session`.
async fn list_dir(
State(s): State<ClientApiState>,
Path(daemon_id): Path<String>,
Query(q): Query<DirQuery>,
) -> Response {
let outcome =
tokio::time::timeout(s.rpc_timeout, s.registry.list_dir(&daemon_id, q.path)).await;
match outcome {
Err(_) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(RegistryError::DaemonNotConnected(_))) => (
StatusCode::NOT_FOUND,
Json(error_body("unknown_daemon", "daemon is not connected")),
)
.into_response(),
Ok(Err(RegistryError::Timeout(_))) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(e)) => (
StatusCode::BAD_GATEWAY,
Json(error_body("transport", &e.to_string())),
)
.into_response(),
Ok(Ok(Err(rpc_err))) => (
dir_error_status(&rpc_err.0.code),
Json(error_body(&rpc_err.0.code, &rpc_err.0.message)),
)
.into_response(),
Ok(Ok(Ok(listing))) => Json::<DirListing>(listing).into_response(),
}
}
/// HTTP status for a daemon-reported directory-listing error code (NAP-329).
fn dir_error_status(code: &str) -> StatusCode {
match code {
"not_found" => StatusCode::NOT_FOUND,
"permission_denied" => StatusCode::FORBIDDEN,
"not_a_directory" => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_GATEWAY,
}
}
async fn create_session(
State(s): State<ClientApiState>,
Json(body): Json<CreateSessionRequest>,
) -> Response {
let daemon = body.daemon_id.clone();
let outcome =
tokio::time::timeout(s.rpc_timeout, s.registry.create_session(&daemon, body)).await;
match outcome {
Err(_) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(RegistryError::DaemonNotConnected(_))) => (
StatusCode::NOT_FOUND,
Json(error_body("unknown_daemon", "daemon is not connected")),
)
.into_response(),
Ok(Err(RegistryError::Timeout(_))) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(e)) => (
StatusCode::BAD_GATEWAY,
Json(error_body("transport", &e.to_string())),
)
.into_response(),
Ok(Ok(Err(rpc_err))) => (
StatusCode::BAD_GATEWAY,
Json(error_body(&rpc_err.0.code, &rpc_err.0.message)),
)
.into_response(),
Ok(Ok(Ok(session))) => (StatusCode::CREATED, Json(session)).into_response(),
}
}
async fn delete_session(State(s): State<ClientApiState>, Path(id): Path<Uuid>) -> Response {
let Some(daemon) = s.registry.daemon_for_session(id).await else {
return (
StatusCode::NOT_FOUND,
Json(error_body("unknown_session", "no such session")),
)
.into_response();
};
let outcome = tokio::time::timeout(s.rpc_timeout, s.registry.kill_session(&daemon, id)).await;
match outcome {
Err(_) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(RegistryError::DaemonNotConnected(_))) => (
StatusCode::NOT_FOUND,
Json(error_body("unknown_daemon", "daemon is not connected")),
)
.into_response(),
Ok(Err(RegistryError::Timeout(_))) => (
StatusCode::GATEWAY_TIMEOUT,
Json(error_body("timeout", "daemon did not reply in time")),
)
.into_response(),
Ok(Err(e)) => (
StatusCode::BAD_GATEWAY,
Json(error_body("transport", &e.to_string())),
)
.into_response(),
Ok(Ok(Err(rpc_err))) => (
StatusCode::BAD_GATEWAY,
Json(error_body(&rpc_err.0.code, &rpc_err.0.message)),
)
.into_response(),
Ok(Ok(Ok(()))) => StatusCode::NO_CONTENT.into_response(),
}
}
async fn session_connect_info(State(s): State<ClientApiState>, Path(id): Path<Uuid>) -> Response {
let Some(daemon) = s.registry.daemon_for_session(id).await else {
return (
StatusCode::NOT_FOUND,
Json(error_body("unknown_session", "no such session")),
)
.into_response();
};
let Some(daemon_url) = s.registry.daemon_url(&daemon).await else {
return (
StatusCode::NOT_FOUND,
Json(error_body("unknown_daemon", "owning daemon is offline")),
)
.into_response();
};
let jwt = match issue_attach_jwt(s.token.as_str(), &daemon, id) {
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(error_body("jwt_failed", &e.to_string())),
)
.into_response();
}
};
Json(ConnectInfo { daemon_url, jwt }).into_response()
}
async fn events_ws_handler(
State(s): State<ClientApiState>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
let registry = s.registry.clone();
ws.on_upgrade(move |socket| async move {
let _ = events_ws(socket, registry).await;
})
}
fn error_body(code: &str, message: &str) -> serde_json::Value {
serde_json::json!({
"error": {
"code": code,
"message": message,
},
})
}
/// Convenience: produce a `ClientApiState` from a `Registry` and shared token.
/// Uses the production [`RPC_TIMEOUT`].
pub fn state_for(registry: Registry, token: String) -> ClientApiState {
ClientApiState {
registry,
token: Arc::new(token),
rpc_timeout: RPC_TIMEOUT,
}
}
-59
View File
@@ -1,59 +0,0 @@
//! `WS /api/events` — registry-side fan-out of catalog + presence deltas.
//!
//! On connect we immediately push a [`RegistryEvent::Snapshot`] of the current
//! state, then forward every broadcast event until the client disconnects.
//! Lagged subscribers receive a [`RegistryEvent::Lagged`] notice with the
//! count of dropped frames — the convention is for the client to re-fetch
//! `GET /api/sessions` on receipt.
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::RegistryEvent;
use tokio::sync::broadcast::error::RecvError;
use crate::state::Registry;
pub async fn events_ws(socket: WebSocket, registry: Registry) -> Result<(), axum::Error> {
let (mut sink, mut stream) = socket.split();
let (snapshot, mut rx) = registry.subscribe().await;
sink.send(Message::Text(
serde_json::to_string(&snapshot).expect("snapshot"),
))
.await?;
loop {
tokio::select! {
ev = rx.recv() => {
match ev {
Ok(event) => {
let text = serde_json::to_string(&event).expect("event encode");
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
Err(RecvError::Lagged(n)) => {
let text = serde_json::to_string(&RegistryEvent::Lagged { skipped: n })
.expect("lagged encode");
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
Err(RecvError::Closed) => break,
}
}
client_frame = stream.next() => {
// Treat anything from the client as either a close or a ping
// we don't care about; the events WS is server-push only.
match client_frame {
None => break,
Some(Err(_)) => break,
Some(Ok(Message::Close(_))) => break,
Some(Ok(_)) => {}
}
}
}
}
Ok(())
}
-325
View File
@@ -1,325 +0,0 @@
//! Per-connection handler for the `WS /daemon` endpoint.
//!
//! The handler is split from the axum-specific glue so it can be exercised
//! directly with an in-process pair of channels in tests. The axum side just
//! adapts a `WebSocket` into the trait below.
use std::time::Duration;
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{DaemonInbound, DaemonOutbound, ErrorInfo, RegistryEvent};
use tokio::sync::broadcast::error::RecvError;
use tokio::sync::mpsc;
use crate::state::Registry;
/// Bound the registry's per-daemon outbound queue. 64 in-flight R→D requests
/// is plenty for a single daemon and bounds memory use under back-pressure.
const OUTBOUND_BUFFER: usize = 64;
/// Run the handshake + message loop on a freshly-accepted WebSocket. Returns
/// the `daemon_id` once the connection terminates (for logging).
///
/// `heartbeat_timeout` is the maximum gap allowed between inbound frames —
/// once exceeded, the connection is dropped and the daemon's sessions are
/// removed from the catalog. Production code passes
/// [`crate::state::HEARTBEAT_TIMEOUT`]; tests use a short value.
pub async fn handle_socket(
socket: WebSocket,
registry: Registry,
expected_token: &str,
heartbeat_timeout: Duration,
) -> Option<String> {
let (mut sink, mut stream) = socket.split();
let hello = match read_hello(&mut stream).await {
Some(h) => h,
None => return None,
};
let (daemon_id, hostname, version, token, daemon_url, home_dir) = match hello {
DaemonOutbound::Hello {
daemon_id,
hostname,
version,
token,
daemon_url,
home_dir,
} => (daemon_id, hostname, version, token, daemon_url, home_dir),
_ => {
let _ = send_error(&mut sink, "expected_hello", "first message must be Hello").await;
return None;
}
};
if token != expected_token {
let _ = send_error(&mut sink, "auth_failed", "invalid token").await;
return None;
}
let (out_tx, mut out_rx) = mpsc::channel::<DaemonInbound>(OUTBOUND_BUFFER);
// Hold the fencing token for the life of this connection; only a deregister
// carrying it may tear down the entry we just installed (NAP-258).
let connection_token = registry
.register(
daemon_id.clone(),
hostname,
version,
daemon_url,
home_dir,
out_tx.clone(),
)
.await;
let _ = sink
.send(Message::Text(
serde_json::to_string(&DaemonInbound::Ack).expect("ack serialize"),
))
.await;
// NAP-246: forward peer-filtered catalog deltas to the daemon. Initial
// snapshot is pushed eagerly; live deltas flow through `catalog_task` →
// `out_tx` so they share the existing outbound writer in the select! loop
// (any extra sink writer would contend with the WS sink+stream coupling
// axum imposes and stall the inbound reader — see history of this file).
let (snapshot, mut catalog_rx) = registry.subscribe().await;
if let Some(peer_snapshot) = snapshot.for_peer(&daemon_id) {
if out_tx
.send(DaemonInbound::CatalogEvent {
event: peer_snapshot,
})
.await
.is_err()
{
registry.deregister(&daemon_id, connection_token).await;
return Some(daemon_id);
}
}
let catalog_id = daemon_id.clone();
let catalog_out = out_tx.clone();
let catalog_task = tokio::spawn(async move {
loop {
let ev = match catalog_rx.recv().await {
Ok(e) => e.for_peer(&catalog_id),
Err(RecvError::Lagged(n)) => Some(RegistryEvent::Lagged { skipped: n }),
Err(RecvError::Closed) => return,
};
let Some(event) = ev else { continue };
if catalog_out
.send(DaemonInbound::CatalogEvent { event })
.await
.is_err()
{
return;
}
}
});
loop {
tokio::select! {
inbound = tokio::time::timeout(heartbeat_timeout, stream.next()) => {
match inbound {
Err(_) => {
tracing::info!(%daemon_id, "heartbeat timeout — dropping");
break;
}
Ok(None) => break,
Ok(Some(Err(e))) => {
tracing::info!(%daemon_id, ?e, "ws read error");
break;
}
Ok(Some(Ok(Message::Close(_)))) => break,
Ok(Some(Ok(Message::Text(text)))) => {
if !dispatch_text(&registry, &daemon_id, expected_token, &text).await {
break;
}
}
Ok(Some(Ok(Message::Binary(_) | Message::Ping(_) | Message::Pong(_)))) => {}
}
}
Some(msg) = out_rx.recv() => {
let text = serde_json::to_string(&msg).expect("serialize daemon-inbound");
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
}
}
catalog_task.abort();
registry.deregister(&daemon_id, connection_token).await;
Some(daemon_id)
}
/// Reads the first text frame, parses it as a `DaemonOutbound`, returns it.
async fn read_hello(
stream: &mut futures_util::stream::SplitStream<WebSocket>,
) -> Option<DaemonOutbound> {
match tokio::time::timeout(Duration::from_secs(5), stream.next()).await {
Ok(Some(Ok(Message::Text(t)))) => serde_json::from_str(&t).ok(),
_ => None,
}
}
async fn dispatch_text(
registry: &Registry,
daemon_id: &str,
shared_secret: &str,
text: &str,
) -> bool {
let parsed: DaemonOutbound = match serde_json::from_str(text) {
Ok(p) => p,
Err(e) => {
// A single malformed frame must not tear down the WS (and wipe this
// daemon's catalog) — log and skip it (NAP-269).
tracing::warn!(%daemon_id, error = %e, "malformed message — skipping");
return true;
}
};
match parsed {
DaemonOutbound::Hello { .. } => {
tracing::warn!(%daemon_id, "second Hello on live connection — ignoring");
}
DaemonOutbound::SessionsSnapshot { sessions } => {
registry.apply_snapshot(daemon_id, sessions).await;
}
DaemonOutbound::SessionAdded { session } => {
registry.add_session(daemon_id, session).await;
}
DaemonOutbound::SessionRemoved { id } => {
registry.remove_session(daemon_id, id).await;
}
DaemonOutbound::SessionUpdated { session } => {
registry.update_session(daemon_id, session).await;
}
DaemonOutbound::Heartbeat { perimeter } => {
// Per-read timeout above is the heartbeat watchdog; capture the
// perimeter advertisement so the broker can hand it back as a T2
// candidate (NAP-247).
registry.update_perimeter(daemon_id, perimeter).await;
}
DaemonOutbound::CreateSessionResult { reply_to, result } => {
registry.complete_create(reply_to, result).await;
}
DaemonOutbound::KillSessionResult { reply_to, result } => {
registry.complete_kill(reply_to, result).await;
}
DaemonOutbound::ResolveAttach {
reply_to,
session_id,
} => {
registry
.resolve_attach(daemon_id, reply_to, session_id, shared_secret)
.await;
}
DaemonOutbound::ForwardCreate { reply_to, request } => {
// Relay the create to its owning daemon and route the reply back to
// this (home) daemon — control-plane only (NAP-327).
registry.forward_create(daemon_id, reply_to, request).await;
}
DaemonOutbound::ForwardKill {
reply_to,
daemon_id: target,
id,
} => {
registry
.forward_kill(daemon_id, reply_to, &target, id)
.await;
}
DaemonOutbound::ListDirResult { reply_to, result } => {
registry.complete_list_dir(reply_to, result).await;
}
DaemonOutbound::ForwardListDir {
reply_to,
daemon_id: target,
path,
} => {
// Relay a directory listing to its owning daemon and route the reply
// back to this (home) daemon — control-plane only (NAP-329).
registry
.forward_list_dir(daemon_id, reply_to, &target, path)
.await;
}
DaemonOutbound::RecentDirsResult { reply_to, result } => {
registry.complete_recent_dirs(reply_to, result).await;
}
DaemonOutbound::ForwardRecentDirs {
reply_to,
daemon_id: target,
} => {
// Relay a recent-dirs query to its owning daemon and route the reply
// back to this (home) daemon — control-plane only (#26).
registry
.forward_recent_dirs(daemon_id, reply_to, &target)
.await;
}
DaemonOutbound::GitRootResult { reply_to, result } => {
registry.complete_git_root(reply_to, result).await;
}
DaemonOutbound::ForwardGitRoot {
reply_to,
daemon_id: target,
path,
} => {
// Relay a gitroot probe to its owning daemon and route the reply
// back to this (home) daemon — control-plane only (#26).
registry
.forward_git_root(daemon_id, reply_to, &target, path)
.await;
}
DaemonOutbound::RelayAck { reply_to, .. } => {
// T3 (relay) is gated on NAP-238. Land the variant so newer daemons
// don't crash the connection; the registry doesn't act on it yet.
tracing::debug!(%daemon_id, ?reply_to, "RelayAck received but T3 not implemented (NAP-238 pending)");
}
DaemonOutbound::Unknown => {
// A newer daemon emitted a frame type this build doesn't know. Skip
// it rather than dropping the connection (NAP-269).
tracing::debug!(%daemon_id, "unknown frame type — skipping (forward-compat, NAP-269)");
}
}
true
}
async fn send_error(
sink: &mut futures_util::stream::SplitSink<WebSocket, Message>,
code: &str,
message: &str,
) -> Result<(), axum::Error> {
let err = DaemonInbound::Error(ErrorInfo {
code: code.into(),
message: message.into(),
});
let text = serde_json::to_string(&err).expect("serialize error");
sink.send(Message::Text(text)).await
}
#[cfg(test)]
mod tests {
use super::*;
// `dispatch_text` returning `false` is what breaks the handler loop and
// triggers a deregister (catalog wipe). A frame the registry can't act on
// must NOT do that, or a newer daemon emitting an unknown variant gets
// stuck in a tight reconnect loop (NAP-269).
#[tokio::test]
async fn unknown_frame_keeps_connection_alive() {
let registry = Registry::new();
let frame = serde_json::json!({ "type": "FutureVariant", "x": 1 }).to_string();
assert!(
dispatch_text(&registry, "laptop", "tok", &frame).await,
"unknown frame type must be skipped, not drop the WS"
);
}
#[tokio::test]
async fn malformed_frame_keeps_connection_alive() {
let registry = Registry::new();
assert!(
dispatch_text(&registry, "laptop", "tok", "{ not valid json").await,
"malformed frame must be skipped, not drop the WS"
);
}
}
-102
View File
@@ -1,102 +0,0 @@
//! Registry-side JWT issuance for the per-session attach endpoint.
//!
//! The daemon validates these tokens locally with the same shared secret —
//! the registry doesn't see any callback on the attach hot path.
//! Spec §Auth, §"Wire protocol (Client↔Daemon)".
use chrono::{Duration, Utc};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use maestro_protocol::{JwtClaims, PairAttachClaims};
use uuid::Uuid;
/// Spec §Auth: ~5 minutes.
pub const ATTACH_JWT_TTL: Duration = Duration::minutes(5);
/// Pair-scoped attach JWT TTL — short-lived per the connection-orchestration
/// spec (60 s default; ample window for the home daemon to dial T2).
pub const PAIR_ATTACH_JWT_TTL: Duration = Duration::seconds(60);
/// `iss` claim baked into every registry-issued pair JWT. Canonical value
/// lives in `maestro-protocol` so issuers and verifiers can't drift (NAP-266).
pub use maestro_protocol::REGISTRY_ISSUER;
/// Mint a short-lived attach JWT for `(daemon_id, session_id)`.
pub fn issue_attach_jwt(
secret: &str,
daemon_id: &str,
session_id: Uuid,
) -> Result<String, jsonwebtoken::errors::Error> {
let now = Utc::now();
let claims = JwtClaims {
session_id,
daemon_id: daemon_id.to_string(),
exp: (now + ATTACH_JWT_TTL).timestamp(),
iat: Some(now.timestamp()),
};
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, &claims, &key)
}
/// Mint a pair-scoped attach JWT used by the home-daemon broker (NAP-247).
/// `sub` is the home daemon-id; `aud` is the target daemon-id. Target
/// validates signature, `iss == REGISTRY_ISSUER`, `aud == self.daemon_id`,
/// `sid == session`, and `exp > now` before accepting the broker-attach.
/// `sub` is no longer gated against an allow-list (NAP-339) — it's retained
/// for logging/tracing.
pub fn issue_pair_attach_jwt(
secret: &str,
home_daemon_id: &str,
target_daemon_id: &str,
session_id: Uuid,
) -> Result<String, jsonwebtoken::errors::Error> {
let now = Utc::now();
let claims = PairAttachClaims {
iss: REGISTRY_ISSUER.into(),
sub: home_daemon_id.to_string(),
aud: target_daemon_id.to_string(),
sid: session_id,
exp: (now + PAIR_ATTACH_JWT_TTL).timestamp(),
};
let header = Header::new(Algorithm::HS256);
let key = EncodingKey::from_secret(secret.as_bytes());
encode(&header, &claims, &key)
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{decode, DecodingKey, Validation};
#[test]
fn issued_token_verifies_with_shared_secret() {
let secret = "shared-secret";
let daemon = "laptop";
let session = Uuid::new_v4();
let token = issue_attach_jwt(secret, daemon, session).unwrap();
let decoded = decode::<JwtClaims>(
&token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::new(Algorithm::HS256),
)
.unwrap();
assert_eq!(decoded.claims.daemon_id, daemon);
assert_eq!(decoded.claims.session_id, session);
assert!(decoded.claims.exp > Utc::now().timestamp());
}
#[test]
fn pair_jwt_carries_iss_sub_aud_sid() {
let secret = "shared-secret";
let session = Uuid::new_v4();
let token = issue_pair_attach_jwt(secret, "laptop", "konishi", session).unwrap();
let mut v = Validation::new(Algorithm::HS256);
v.set_audience(&["konishi"]);
let decoded =
decode::<PairAttachClaims>(&token, &DecodingKey::from_secret(secret.as_bytes()), &v)
.unwrap();
assert_eq!(decoded.claims.iss, REGISTRY_ISSUER);
assert_eq!(decoded.claims.sub, "laptop");
assert_eq!(decoded.claims.aud, "konishi");
assert_eq!(decoded.claims.sid, session);
assert!(decoded.claims.exp > Utc::now().timestamp());
}
}
-34
View File
@@ -1,34 +0,0 @@
//! The always-on Maestro registry.
//!
//! Covers two surfaces:
//!
//! - Daemon-facing `WS /daemon` (NAP-213): presence + session catalog.
//! - Client-facing HTTP/WS API (NAP-215): list daemons/sessions, spawn/kill
//! sessions (forwarded to the owning daemon via WS), issue per-session
//! attach JWTs, and broadcast catalog deltas over `WS /api/events`.
//!
//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §Registry,
//! §"Wire protocol", and §Auth.
#![deny(missing_debug_implementations)]
pub mod auth;
pub mod client_api;
pub mod events;
pub mod handler;
pub mod jwt;
pub mod run;
pub mod server;
pub mod state;
pub mod token;
pub use run::run;
pub use auth::{issue_cookie, verify_cookie, COOKIE_NAME};
pub use jwt::{issue_attach_jwt, ATTACH_JWT_TTL};
pub use server::{router, router_full, router_with_timeout, RegistryServer};
pub use state::{DaemonView, Registry, RegistryError, RpcError, HEARTBEAT_TIMEOUT, RPC_TIMEOUT};
pub use token::{
default_config_path, ensure_token, load_config, resolve_config_path, RegistryConfig,
TokenError, CONFIG_PATH_ENV, ENV_REGISTRY_BIND_ADDR, ENV_TOKEN,
};
-51
View File
@@ -1,51 +0,0 @@
//! Public entry point: load config, bind, serve until SIGINT.
//!
//! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro`
//! binary can dispatch `maestro registry` here.
use std::error::Error;
use std::path::PathBuf;
use tokio::net::TcpListener;
use crate::{
default_config_path, load_config, router, Registry, ENV_REGISTRY_BIND_ADDR, ENV_TOKEN,
};
/// Run the registry. `config_override` is the top-level `--config` flag from
/// the umbrella binary (also populated from `$MAESTRO_CONFIG` by clap).
pub async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let config_path: PathBuf = match config_override {
Some(p) => p,
None => default_config_path()?,
};
let env_token = std::env::var(ENV_TOKEN).ok();
let env_bind = std::env::var(ENV_REGISTRY_BIND_ADDR).ok();
let token_from_env = env_token.is_some();
let bind_from_env = env_bind.is_some();
let config = load_config(&config_path, env_token, env_bind.as_deref())?;
tracing::info!(
config = %config_path.display(),
bind = %config.bind_addr,
token_from_env,
bind_from_env,
"loaded config"
);
let listener = TcpListener::bind(config.bind_addr).await?;
let bound = listener.local_addr()?;
tracing::info!(addr = %bound, "registry listening");
let registry = Registry::new();
let app = router(registry, config.token);
let shutdown = async {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("ctrl-c received — shutting down");
};
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await?;
Ok(())
}
-110
View File
@@ -1,110 +0,0 @@
//! axum router + listener for the registry, including both the daemon-facing
//! `WS /daemon` endpoint (NAP-213) and the client-facing HTTP API (NAP-215).
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::WebSocketUpgrade;
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::any;
use axum::{middleware, Router};
use tokio::net::TcpListener;
use crate::auth::{cookie_middleware, AuthState};
use crate::client_api::{routes as client_routes, ClientApiState};
use crate::handler::handle_socket;
use crate::state::{Registry, HEARTBEAT_TIMEOUT, RPC_TIMEOUT};
#[derive(Debug, Clone)]
struct DaemonAppState {
registry: Registry,
token: Arc<String>,
heartbeat_timeout: Duration,
}
/// Build the production router (daemon WS + client HTTP API) with default
/// timeouts.
pub fn router(registry: Registry, token: String) -> Router {
router_full(registry, token, HEARTBEAT_TIMEOUT, RPC_TIMEOUT)
}
/// Variant for integration tests that want a short heartbeat. Keeps the
/// existing 4-arg call sites in `daemon_ws.rs` working.
pub fn router_with_timeout(
registry: Registry,
token: String,
heartbeat_timeout: Duration,
) -> Router {
router_full(registry, token, heartbeat_timeout, RPC_TIMEOUT)
}
/// Full builder; lets tests override every knob.
pub fn router_full(
registry: Registry,
token: String,
heartbeat_timeout: Duration,
rpc_timeout: Duration,
) -> Router {
let token_arc = Arc::new(token.clone());
let daemon_router = Router::new()
.route("/daemon", any(daemon_ws))
.with_state(DaemonAppState {
registry: registry.clone(),
token: token_arc.clone(),
heartbeat_timeout,
});
let client_router = client_routes(ClientApiState {
registry,
token: token_arc.clone(),
rpc_timeout,
})
.layer(middleware::from_fn_with_state(
AuthState {
token: token_arc.clone(),
},
cookie_middleware,
));
Router::new().merge(daemon_router).merge(client_router)
}
async fn daemon_ws(State(state): State<DaemonAppState>, ws: WebSocketUpgrade) -> impl IntoResponse {
let registry = state.registry.clone();
let token = state.token.clone();
let timeout = state.heartbeat_timeout;
ws.on_upgrade(move |socket| async move {
let _ = handle_socket(socket, registry, token.as_str(), timeout).await;
})
}
/// Convenience: bind a TCP listener and serve the router on it.
#[derive(Debug)]
pub struct RegistryServer {
pub addr: SocketAddr,
pub registry: Registry,
}
impl RegistryServer {
/// Bind to `bind_addr` and serve in-place. Returns the bound `SocketAddr`
/// (so callers passing `:0` can discover it) and a handle to the registry
/// state. The future runs until the listener is dropped.
pub async fn bind(
bind_addr: SocketAddr,
token: String,
) -> std::io::Result<(Self, impl std::future::Future<Output = std::io::Result<()>>)> {
let listener = TcpListener::bind(bind_addr).await?;
let addr = listener.local_addr()?;
let registry = Registry::new();
let app = router(registry.clone(), token);
let server = Self {
addr,
registry: registry.clone(),
};
let fut = async move { axum::serve(listener, app).await };
Ok((server, fut))
}
}
File diff suppressed because it is too large Load Diff
-289
View File
@@ -1,289 +0,0 @@
//! Registry view over the unified Maestro config + first-run token bootstrap.
//!
//! On startup the registry looks for the unified file at
//! `~/.config/maestro/config.toml`:
//!
//! - If `[shared].token` is present, loads it.
//! - If absent, generates a 256-bit random token, prints it once to stderr,
//! and persists it back to the same file via read-modify-write (so any
//! `[cli]` / `[daemon]` sections from a co-located host stay intact).
//! Subsequent runs are silent.
//!
//! Env-var overrides (applied by [`load_config`]):
//!
//! - `MAESTRO_TOKEN` — when set, bypasses the file entirely (no read,
//! no write). Lets the binary run in a container with no writable volume.
//! - `MAESTRO_REGISTRY_BIND_ADDR` — when set, overrides `bind_addr`.
//!
//! Default config path: `~/.config/maestro/config.toml` (override via
//! `$MAESTRO_CONFIG`).
use std::net::SocketAddr;
use std::path::Path;
use maestro_config::MaestroConfigFile;
use rand::RngCore;
pub use maestro_config::{default_config_path, resolve_config_path, CONFIG_PATH_ENV};
/// Default `bind_addr` when neither file nor env specify one.
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
/// Env var that overrides `[shared].token`.
pub const ENV_TOKEN: &str = "MAESTRO_TOKEN";
/// Env var that overrides `[registry].bind_addr`.
pub const ENV_REGISTRY_BIND_ADDR: &str = "MAESTRO_REGISTRY_BIND_ADDR";
fn default_bind_addr() -> SocketAddr {
DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr")
}
#[derive(Debug, thiserror::Error)]
pub enum TokenError {
#[error(transparent)]
Inner(#[from] maestro_config::ConfigError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
/// Resolved registry config: the token to sign JWTs with and the address
/// to bind on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryConfig {
pub token: String,
pub bind_addr: SocketAddr,
}
/// Load `[shared].token` (or generate one and persist it). Returns the
/// resolved [`RegistryConfig`] populated with `[registry].bind_addr` when
/// present, else the default.
///
/// On first run (no `[shared].token` in the file) this prints the freshly
/// generated token to stderr so the operator can copy it to every daemon.
pub fn ensure_token(config_path: &Path) -> Result<RegistryConfig, TokenError> {
let mut file = MaestroConfigFile::load_or_default(config_path)?;
let bind_addr = file.registry.bind_addr.unwrap_or_else(default_bind_addr);
if let Some(token) = file.shared.token.clone() {
return Ok(RegistryConfig { token, bind_addr });
}
let token = generate_token();
file.shared.token = Some(token.clone());
file.save(config_path)?;
eprintln!(
"maestro-registry: generated new MAESTRO_TOKEN; deploy this to every daemon:\n {token}"
);
Ok(RegistryConfig { token, bind_addr })
}
/// Resolve the runtime config, applying env-var overrides on top of (or
/// instead of) the on-disk config file.
///
/// Precedence per field: env > file > default.
///
/// `env_token` (`MAESTRO_TOKEN`): when `Some`, the file is neither
/// read nor created — useful in container deployments without a writable
/// volume.
/// `env_bind_addr` (`MAESTRO_REGISTRY_BIND_ADDR`): when `Some`, overrides
/// the bind address.
///
/// Pass real env values from `main`; tests pass explicit `Option`s.
pub fn load_config(
config_path: &Path,
env_token: Option<String>,
env_bind_addr: Option<&str>,
) -> Result<RegistryConfig, TokenError> {
let mut cfg = match env_token {
Some(token) => RegistryConfig {
token,
bind_addr: default_bind_addr(),
},
None => ensure_token(config_path)?,
};
if let Some(raw) = env_bind_addr {
cfg.bind_addr = maestro_config::parse_bind_addr(raw, ENV_REGISTRY_BIND_ADDR)?;
}
Ok(cfg)
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
hex_encode(&bytes)
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0xf) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_run_generates_persistent_token_in_shared_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = ensure_token(&path).unwrap();
assert_eq!(cfg.token.len(), 64); // 32 bytes hex-encoded
assert_eq!(cfg.bind_addr, default_bind_addr());
let again = ensure_token(&path).unwrap();
assert_eq!(cfg, again, "second call must return same token");
let file = MaestroConfigFile::load(&path).unwrap();
assert_eq!(file.shared.token.as_deref(), Some(cfg.token.as_str()));
}
#[test]
fn first_run_preserves_other_sections() {
// A host running both daemon and registry already has the daemon's
// config in the file. Registry's first-run token gen must not lose
// [daemon] or [shared].registry_url.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
registry_url = "https://r.example"
[daemon]
daemon_url = "ws://laptop:7070"
"#,
)
.unwrap();
let cfg = ensure_token(&path).unwrap();
assert_eq!(cfg.token.len(), 64);
let file = MaestroConfigFile::load(&path).unwrap();
assert_eq!(
file.shared.registry_url.as_deref(),
Some("https://r.example")
);
assert_eq!(file.shared.token.as_deref(), Some(cfg.token.as_str()));
assert_eq!(file.daemon.daemon_url.as_deref(), Some("ws://laptop:7070"));
}
#[test]
fn creates_parent_directory() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested/here/config.toml");
let cfg = ensure_token(&path).unwrap();
assert!(path.exists());
assert_eq!(cfg.token.len(), 64);
}
#[test]
fn existing_token_is_loaded_as_is() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
token = "abcd"
"#,
)
.unwrap();
let cfg = ensure_token(&path).unwrap();
assert_eq!(cfg.token, "abcd");
assert_eq!(cfg.bind_addr, default_bind_addr());
}
#[test]
fn custom_bind_addr_is_preserved() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
token = "x"
[registry]
bind_addr = "0.0.0.0:9999"
"#,
)
.unwrap();
let cfg = ensure_token(&path).unwrap();
assert_eq!(cfg.bind_addr, "0.0.0.0:9999".parse().unwrap());
}
#[test]
fn env_token_bypasses_file_entirely() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = load_config(&path, Some("envtoken".into()), None).unwrap();
assert_eq!(cfg.token, "envtoken");
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(
!path.exists(),
"no file should be created when env token is set"
);
}
#[test]
fn env_bind_addr_overrides_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[shared]
token = "file"
[registry]
bind_addr = "127.0.0.1:1"
"#,
)
.unwrap();
let cfg = load_config(&path, None, Some("0.0.0.0:9090")).unwrap();
assert_eq!(cfg.token, "file");
assert_eq!(cfg.bind_addr, "0.0.0.0:9090".parse().unwrap());
}
#[test]
fn env_token_and_env_bind_addr_combined() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = load_config(&path, Some("t".into()), Some("0.0.0.0:7777")).unwrap();
assert_eq!(cfg.token, "t");
assert_eq!(cfg.bind_addr, "0.0.0.0:7777".parse().unwrap());
assert!(!path.exists());
}
#[test]
fn invalid_env_bind_addr_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let err = load_config(&path, Some("t".into()), Some("not-an-addr")).unwrap_err();
match err {
TokenError::Inner(maestro_config::ConfigError::InvalidBindAddr {
input, env, ..
}) => {
assert_eq!(input, "not-an-addr");
assert_eq!(env, ENV_REGISTRY_BIND_ADDR);
}
other => panic!("expected InvalidBindAddr, got {other:?}"),
}
}
#[test]
fn no_env_falls_through_to_ensure_token() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let cfg = load_config(&path, None, None).unwrap();
assert_eq!(cfg.token.len(), 64);
assert!(path.exists());
}
}
-391
View File
@@ -1,391 +0,0 @@
//! End-to-end tests for the `WS /daemon` endpoint, driven by a real
//! `tokio-tungstenite` client against an axum server bound to `:0`.
use std::collections::HashMap;
use std::future::Future;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
CreateSessionRequest, DaemonInbound, DaemonOutbound, RpcResult, Session, SessionStatus,
};
use maestro_registry::{router_with_timeout, Registry};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use uuid::Uuid;
const TOKEN: &str = "test-token-please-allow";
const TEST_HEARTBEAT: Duration = Duration::from_millis(500);
struct Harness {
addr: SocketAddr,
registry: Registry,
_shutdown_tx: oneshot::Sender<()>,
}
async fn spawn_registry() -> Harness {
spawn_registry_with_timeout(TEST_HEARTBEAT).await
}
async fn spawn_registry_with_timeout(heartbeat: Duration) -> Harness {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let registry = Registry::new();
let app = router_with_timeout(registry.clone(), TOKEN.to_string(), heartbeat);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
Harness {
addr,
registry,
_shutdown_tx: shutdown_tx,
}
}
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
async fn connect(addr: SocketAddr) -> WsClient {
let url = format!("ws://{addr}/daemon");
let (ws, _) = connect_async(url).await.expect("ws connect");
ws
}
async fn send_json(ws: &mut WsClient, msg: &DaemonOutbound) {
let text = serde_json::to_string(msg).unwrap();
ws.send(WsMessage::Text(text)).await.unwrap();
}
async fn recv_inbound(ws: &mut WsClient) -> Option<DaemonInbound> {
while let Some(Ok(msg)) = ws.next().await {
if let WsMessage::Text(t) = msg {
let parsed: DaemonInbound = serde_json::from_str(&t).unwrap();
// NAP-246: skip catalog frames — these are informational and not
// what most tests are asserting on.
if matches!(parsed, DaemonInbound::CatalogEvent { .. }) {
continue;
}
return Some(parsed);
}
}
None
}
fn hello(token: &str) -> DaemonOutbound {
DaemonOutbound::Hello {
daemon_id: "laptop".into(),
hostname: "naps-laptop".into(),
version: "0.1.0".into(),
token: token.into(),
daemon_url: "ws://127.0.0.1:8081".into(),
home_dir: String::new(),
}
}
fn sample_session(id: Uuid, daemon_id: &str) -> Session {
Session {
id,
daemon_id: daemon_id.to_string(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
status: SessionStatus::Running,
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
async fn wait_for<F, Fut>(timeout: Duration, mut cond: F)
where
F: FnMut() -> Fut,
Fut: Future<Output = bool>,
{
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if cond().await {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out after {timeout:?} waiting for condition");
}
// --------------------------------------------------------------------------
// Tests
// --------------------------------------------------------------------------
#[tokio::test]
async fn rejects_bad_token() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello("wrong")).await;
let reply = recv_inbound(&mut ws).await.expect("error frame");
match reply {
DaemonInbound::Error(e) => assert_eq!(e.code, "auth_failed"),
other => panic!("expected Error, got {other:?}"),
}
let _ = ws.next().await;
assert!(h.registry.daemons().await.is_empty());
}
#[tokio::test]
async fn accepts_good_token_and_snapshot() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let ack = recv_inbound(&mut ws).await.expect("ack");
assert!(matches!(ack, DaemonInbound::Ack));
let sessions = vec![
sample_session(Uuid::new_v4(), "laptop"),
sample_session(Uuid::new_v4(), "laptop"),
];
send_json(
&mut ws,
&DaemonOutbound::SessionsSnapshot {
sessions: sessions.clone(),
},
)
.await;
let expected = sessions.len();
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == expected }
})
.await;
let daemons = h.registry.daemons().await;
assert_eq!(daemons.len(), 1);
assert_eq!(daemons[0].daemon_id, "laptop");
}
#[tokio::test]
async fn applies_individual_diffs() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws).await;
let s1 = sample_session(Uuid::new_v4(), "laptop");
let s2 = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws,
&DaemonOutbound::SessionAdded {
session: s1.clone(),
},
)
.await;
send_json(
&mut ws,
&DaemonOutbound::SessionAdded {
session: s2.clone(),
},
)
.await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == 2 }
})
.await;
let mut s1_updated = s1.clone();
s1_updated.command = "fish".into();
send_json(
&mut ws,
&DaemonOutbound::SessionUpdated {
session: s1_updated.clone(),
},
)
.await;
send_json(&mut ws, &DaemonOutbound::SessionRemoved { id: s2.id }).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move {
let s = r.sessions().await;
s.len() == 1 && s[0].command == "fish"
}
})
.await;
}
#[tokio::test]
async fn heartbeat_timeout_drops_daemon() {
let h = spawn_registry_with_timeout(Duration::from_millis(200)).await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws).await;
send_json(
&mut ws,
&DaemonOutbound::SessionAdded {
session: sample_session(Uuid::new_v4(), "laptop"),
},
)
.await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == 1 }
})
.await;
// Hold the WS open but never send another frame. The server's per-read
// timeout should fire after 200ms and drop the daemon.
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.daemons().await.is_empty() && r.sessions().await.is_empty() }
})
.await;
}
#[tokio::test]
async fn re_registration_replaces_catalog() {
let h = spawn_registry().await;
let mut ws1 = connect(h.addr).await;
send_json(&mut ws1, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws1).await;
let s_old = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws1,
&DaemonOutbound::SessionAdded {
session: s_old.clone(),
},
)
.await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.sessions().await.len() == 1 }
})
.await;
drop(ws1);
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { r.daemons().await.is_empty() }
})
.await;
let mut ws2 = connect(h.addr).await;
send_json(&mut ws2, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws2).await;
let s_new = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws2,
&DaemonOutbound::SessionsSnapshot {
sessions: vec![s_new.clone()],
},
)
.await;
let registry = h.registry.clone();
let want_id = s_new.id;
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move {
let s = r.sessions().await;
s.len() == 1 && s[0].id == want_id
}
})
.await;
}
#[tokio::test]
async fn create_session_rpc_round_trip() {
let h = spawn_registry().await;
let mut ws = connect(h.addr).await;
send_json(&mut ws, &hello(TOKEN)).await;
let _ = recv_inbound(&mut ws).await;
let registry = h.registry.clone();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let rpc = tokio::spawn(async move { registry.create_session("laptop", request).await });
let inbound = recv_inbound(&mut ws).await.expect("CreateSession");
let (reply_to, _request) = match inbound {
DaemonInbound::CreateSession { reply_to, request } => (reply_to, request),
other => panic!("expected CreateSession, got {other:?}"),
};
let session = sample_session(Uuid::new_v4(), "laptop");
send_json(
&mut ws,
&DaemonOutbound::CreateSessionResult {
reply_to,
result: RpcResult::Ok {
value: session.clone(),
},
},
)
.await;
let outcome = rpc.await.unwrap().expect("rpc transport ok");
let returned = outcome.expect("rpc daemon ok");
assert_eq!(returned.id, session.id);
}
#[tokio::test]
async fn create_session_to_unknown_daemon_errors() {
let h = spawn_registry().await;
let err = h
.registry
.create_session(
"no-such-daemon",
CreateSessionRequest {
daemon_id: "no-such-daemon".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
},
)
.await
.unwrap_err();
assert!(matches!(
err,
maestro_registry::RegistryError::DaemonNotConnected(_)
));
}
-728
View File
@@ -1,728 +0,0 @@
//! Integration tests for the client-facing HTTP API (NAP-215).
//!
//! Spins up a registry on `:0`, plus a tiny mock-daemon WS client that exists
//! purely to satisfy `CreateSession` / `KillSession` round-trips. All
//! HTTP/WS calls go over the wire via `reqwest` and `tokio-tungstenite`.
use std::collections::HashMap;
use std::future::Future;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use maestro_protocol::{
ConnectInfo, CreateSessionRequest, DaemonInbound, DaemonInfo, DaemonOutbound, JwtClaims,
RegistryEvent, RpcResult, Session, SessionStatus,
};
use maestro_registry::{router_full, Registry, COOKIE_NAME};
use reqwest::Client;
use serde_json::Value;
use tokio::net::TcpListener;
use tokio::sync::{oneshot, Mutex};
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{
connect_async, tungstenite::client::IntoClientRequest, MaybeTlsStream, WebSocketStream,
};
use uuid::Uuid;
const TOKEN: &str = "http-api-test-token";
const TEST_HEARTBEAT: Duration = Duration::from_secs(30);
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
struct Harness {
addr: SocketAddr,
registry: Registry,
_shutdown_tx: oneshot::Sender<()>,
}
impl Harness {
fn base(&self) -> String {
format!("http://{}", self.addr)
}
fn ws_base(&self) -> String {
format!("ws://{}", self.addr)
}
}
async fn spawn(rpc_timeout: Duration) -> Harness {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let registry = Registry::new();
let app = router_full(
registry.clone(),
TOKEN.to_string(),
TEST_HEARTBEAT,
rpc_timeout,
);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
Harness {
addr,
registry,
_shutdown_tx: shutdown_tx,
}
}
// ---------------------------------------------------------------------------
// Mock daemon
// ---------------------------------------------------------------------------
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
#[derive(Clone, Copy)]
enum DaemonBehavior {
/// Replies immediately with the session the request asked for.
ReplyOk,
/// Never replies — exercises the registry's 504 timeout path.
Hang,
}
#[derive(Clone)]
struct MockDaemon {
daemon_id: String,
daemon_url: String,
captured: Arc<Mutex<Vec<DaemonInbound>>>,
}
impl MockDaemon {
fn new(daemon_id: &str, daemon_url: &str) -> Self {
Self {
daemon_id: daemon_id.into(),
daemon_url: daemon_url.into(),
captured: Arc::new(Mutex::new(Vec::new())),
}
}
async fn spawn(self, addr: SocketAddr, behavior: DaemonBehavior) {
let url = format!("ws://{addr}/daemon");
let (mut ws, _) = connect_async(url).await.expect("ws connect");
let hello = DaemonOutbound::Hello {
daemon_id: self.daemon_id.clone(),
hostname: "mock-host".into(),
version: "0.1.0".into(),
token: TOKEN.into(),
daemon_url: self.daemon_url.clone(),
home_dir: String::new(),
};
ws.send(WsMessage::Text(serde_json::to_string(&hello).unwrap()))
.await
.unwrap();
// consume the Ack
let _ = ws.next().await;
// send an empty initial snapshot so the registry's view is consistent
ws.send(WsMessage::Text(
serde_json::to_string(&DaemonOutbound::SessionsSnapshot { sessions: vec![] }).unwrap(),
))
.await
.unwrap();
let captured = self.captured.clone();
let daemon_id = self.daemon_id.clone();
tokio::spawn(async move {
run_loop(ws, behavior, captured, daemon_id).await;
});
}
async fn captured(&self) -> Vec<DaemonInbound> {
self.captured.lock().await.clone()
}
}
async fn run_loop(
mut ws: WsClient,
behavior: DaemonBehavior,
captured: Arc<Mutex<Vec<DaemonInbound>>>,
daemon_id: String,
) {
while let Some(Ok(WsMessage::Text(text))) = ws.next().await {
let msg: DaemonInbound = match serde_json::from_str(&text) {
Ok(m) => m,
Err(_) => continue,
};
captured.lock().await.push(msg.clone());
match (behavior, msg) {
(DaemonBehavior::ReplyOk, DaemonInbound::CreateSession { reply_to, request }) => {
let session = mock_session(&daemon_id, &request);
// Send SessionAdded BEFORE the RPC result so the registry's
// catalog is updated by the time the HTTP caller resumes.
// The registry processes inbound frames serially in order,
// so this ordering guarantee holds end-to-end.
let _ = ws
.send(WsMessage::Text(
serde_json::to_string(&DaemonOutbound::SessionAdded {
session: session.clone(),
})
.unwrap(),
))
.await;
let reply = DaemonOutbound::CreateSessionResult {
reply_to,
result: RpcResult::Ok { value: session },
};
let _ = ws
.send(WsMessage::Text(serde_json::to_string(&reply).unwrap()))
.await;
}
(DaemonBehavior::ReplyOk, DaemonInbound::KillSession { reply_to, id }) => {
let _ = ws
.send(WsMessage::Text(
serde_json::to_string(&DaemonOutbound::SessionRemoved { id }).unwrap(),
))
.await;
let reply = DaemonOutbound::KillSessionResult {
reply_to,
result: RpcResult::Ok { value: () },
};
let _ = ws
.send(WsMessage::Text(serde_json::to_string(&reply).unwrap()))
.await;
}
(DaemonBehavior::Hang, _) => {
// intentionally do nothing
}
_ => {}
}
}
}
fn mock_session(daemon_id: &str, request: &CreateSessionRequest) -> Session {
// If a worktree spec was attached, pretend the daemon "resolved" the cwd
// to <base_repo>/.worktrees/<branch> so the test can assert on it.
let cwd = request
.worktree
.as_ref()
.map(|w| w.base_repo.join(".worktrees").join(&w.branch))
.unwrap_or_else(|| request.cwd.clone());
Session {
id: Uuid::new_v4(),
daemon_id: daemon_id.into(),
cwd,
command: request.command.clone(),
env: request.env.clone(),
metadata: request.metadata.clone(),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async fn wait_for<F, Fut>(timeout: Duration, mut cond: F)
where
F: FnMut() -> Fut,
Fut: Future<Output = bool>,
{
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if cond().await {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out after {timeout:?} waiting for condition");
}
fn client_with_cookies() -> Client {
reqwest::Client::builder()
.cookie_store(true)
.build()
.unwrap()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn first_request_sets_cookie_and_subsequent_reuses_it() {
let h = spawn(Duration::from_secs(5)).await;
let client = client_with_cookies();
let r1 = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
assert!(r1.status().is_success());
let cookies: Vec<_> = r1
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.collect();
assert!(
!cookies.is_empty(),
"first request must include a Set-Cookie"
);
let raw = cookies[0].to_str().unwrap();
assert!(raw.contains(COOKIE_NAME));
assert!(raw.contains("HttpOnly"));
assert!(raw.contains("SameSite=Strict"));
let r2 = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
assert!(r2.status().is_success());
let new_cookies: Vec<_> = r2
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.collect();
assert!(
new_cookies.is_empty(),
"valid existing cookie should not be reissued, got {new_cookies:?}"
);
}
#[tokio::test]
async fn tampered_cookie_is_replaced_silently() {
let h = spawn(Duration::from_secs(5)).await;
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/api/daemons", h.base()))
.header(
reqwest::header::COOKIE,
format!("{}=not-a-jwt", COOKIE_NAME),
)
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let cookies: Vec<_> = resp
.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.collect();
assert!(
!cookies.is_empty(),
"tampered cookie must be replaced with a freshly-signed one"
);
}
#[tokio::test]
async fn list_daemons_includes_url() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let resp = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let body: Vec<DaemonInfo> = resp.json().await.unwrap();
assert_eq!(body.len(), 1);
assert_eq!(body[0].daemon_id, "laptop");
assert_eq!(body[0].daemon_url, "ws://10.0.0.1:8081");
}
#[tokio::test]
async fn post_sessions_round_trips_to_daemon() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let resp = client
.post(format!("{}/api/sessions", h.base()))
.json(&request)
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 201);
let session: Session = resp.json().await.unwrap();
assert_eq!(session.daemon_id, "laptop");
assert_eq!(session.command, "bash");
// GET /api/sessions should now see it.
let resp = client
.get(format!("{}/api/sessions", h.base()))
.send()
.await
.unwrap();
let listed: Vec<Session> = resp.json().await.unwrap();
assert!(listed.iter().any(|s| s.id == session.id));
}
#[tokio::test]
async fn post_sessions_returns_504_on_daemon_timeout() {
// Short RPC timeout so the 504 path is fast.
let h = spawn(Duration::from_millis(200)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::Hang).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let resp = client
.post(format!("{}/api/sessions", h.base()))
.json(&request)
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 504);
// The daemon should have received the CreateSession even though it never
// replied.
let captured = mock.captured().await;
assert!(
captured
.iter()
.any(|m| matches!(m, DaemonInbound::CreateSession { .. })),
"daemon should have seen CreateSession, got {captured:?}"
);
}
#[tokio::test]
async fn post_sessions_with_worktree_forwards_spec_and_returns_resolved_cwd() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let body = serde_json::json!({
"daemon_id": "laptop",
"cwd": "/home/naps62/projects/maestro",
"command": "claude",
"worktree": {
"base_repo": "/home/naps62/projects/maestro",
"branch": "feat/test",
"cleanup_on_kill": true
}
});
let resp = client
.post(format!("{}/api/sessions", h.base()))
.json(&body)
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 201);
let session: Session = resp.json().await.unwrap();
assert_eq!(
session.cwd,
PathBuf::from("/home/naps62/projects/maestro/.worktrees/feat/test")
);
let captured = mock.captured().await;
let forwarded = captured
.iter()
.find_map(|m| match m {
DaemonInbound::CreateSession { request, .. } => Some(request.clone()),
_ => None,
})
.expect("CreateSession was not forwarded");
let spec = forwarded.worktree.expect("worktree spec dropped");
assert_eq!(spec.branch, "feat/test");
assert!(spec.cleanup_on_kill);
}
#[tokio::test]
async fn delete_session_round_trips() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let session: Session = client
.post(format!("{}/api/sessions", h.base()))
.json(&request)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
// The mock daemon doesn't actually update its catalog after kill — but the
// registry's catalog reflects whatever the daemon broadcasts. We're
// asserting only on the HTTP response.
let resp = client
.delete(format!("{}/api/sessions/{}", h.base(), session.id))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 204);
let captured = mock.captured().await;
assert!(
captured
.iter()
.any(|m| matches!(m, DaemonInbound::KillSession { id, .. } if *id == session.id)),
"daemon should have seen KillSession, got {captured:?}"
);
}
#[tokio::test]
async fn delete_session_returns_404_for_unknown_session() {
let h = spawn(Duration::from_secs(5)).await;
let client = client_with_cookies();
let resp = client
.delete(format!("{}/api/sessions/{}", h.base(), Uuid::new_v4()))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 404);
}
#[tokio::test]
async fn connect_info_issues_valid_jwt() {
let h = spawn(Duration::from_secs(5)).await;
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let registry = h.registry.clone();
wait_for(Duration::from_secs(2), || {
let r = registry.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let client = client_with_cookies();
let session: Session = client
.post(format!("{}/api/sessions", h.base()))
.json(&CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let resp = client
.get(format!(
"{}/api/sessions/{}/connect-info",
h.base(),
session.id
))
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let info: ConnectInfo = resp.json().await.unwrap();
assert_eq!(info.daemon_url, "ws://10.0.0.1:8081");
// Verify the JWT against the same shared secret a daemon would use.
let decoded = decode::<JwtClaims>(
&info.jwt,
&DecodingKey::from_secret(TOKEN.as_bytes()),
&Validation::new(Algorithm::HS256),
)
.expect("jwt decodes");
assert_eq!(decoded.claims.daemon_id, "laptop");
assert_eq!(decoded.claims.session_id, session.id);
assert!(decoded.claims.exp > Utc::now().timestamp());
}
#[tokio::test]
async fn events_ws_pushes_snapshot_then_deltas() {
let h = spawn(Duration::from_secs(5)).await;
// First obtain a valid cookie via a plain HTTP call.
let client = client_with_cookies();
let _ = client
.get(format!("{}/api/daemons", h.base()))
.send()
.await
.unwrap();
let cookie_value = harvest_cookie(&client, &h.base()).await;
// Now open the events WS with that cookie.
let url = format!("{}/api/events", h.ws_base());
let mut req = url.into_client_request().unwrap();
req.headers_mut().insert(
reqwest::header::COOKIE,
format!("{}={}", COOKIE_NAME, cookie_value).parse().unwrap(),
);
let (mut ws, _) = connect_async(req).await.expect("connect events ws");
let snapshot = recv_event(&mut ws).await;
match snapshot {
RegistryEvent::Snapshot { daemons, sessions } => {
assert!(daemons.is_empty());
assert!(sessions.is_empty());
}
other => panic!("expected Snapshot first, got {other:?}"),
}
// Now connect a daemon — subscriber should see DaemonAdded.
let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081");
mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await;
let event = recv_event(&mut ws).await;
match event {
RegistryEvent::DaemonAdded { daemon } => assert_eq!(daemon.daemon_id, "laptop"),
other => panic!("expected DaemonAdded, got {other:?}"),
}
// Spawn a session via HTTP; subscriber should see SessionAdded.
let _: Session = client
.post(format!("{}/api/sessions", h.base()))
.json(&CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: PathBuf::from("/tmp"),
command: "bash".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let event = recv_event(&mut ws).await;
assert!(
matches!(event, RegistryEvent::SessionAdded { .. }),
"expected SessionAdded, got {event:?}"
);
}
#[tokio::test]
async fn root_serves_no_ui() {
// The registry serves no web UI — that's `maestro-web`'s job (see
// connection-orchestration spec §"UI hosting"). `/` has no route.
let h = spawn(Duration::from_secs(5)).await;
let client = client_with_cookies();
let resp = client.get(h.base()).send().await.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
async fn harvest_cookie(client: &Client, base: &str) -> String {
// Reqwest's cookie store keeps the cookie, but doesn't expose values
// directly. Easiest: re-issue a request without the store and read the
// Set-Cookie that comes back.
let bare = reqwest::Client::new();
let resp = bare
.get(format!("{base}/api/daemons"))
.send()
.await
.unwrap();
let raw = resp
.headers()
.get(reqwest::header::SET_COOKIE)
.expect("set-cookie")
.to_str()
.unwrap();
// raw looks like "maestro_session=<value>; HttpOnly; ..."
let prefix = format!("{COOKIE_NAME}=");
let body = raw.strip_prefix(&prefix).expect("cookie prefix");
let value = body.split(';').next().unwrap();
let _ = client; // unused — kept in signature for symmetry
value.to_string()
}
async fn recv_event(ws: &mut WsClient) -> RegistryEvent {
let frame = tokio::time::timeout(Duration::from_secs(2), ws.next())
.await
.expect("event arrived")
.expect("ws open")
.expect("ws frame");
match frame {
WsMessage::Text(t) => match serde_json::from_str::<RegistryEvent>(&t) {
Ok(e) => e,
Err(e) => {
let parsed: Value = serde_json::from_str(&t).unwrap();
panic!("could not parse event {parsed:?}: {e}");
}
},
other => panic!("expected text frame, got {other:?}"),
}
}
+18
View File
@@ -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
+364
View File
@@ -0,0 +1,364 @@
//! An in-memory Maestro daemon for tests.
//!
//! Serves the real `/api/*` surface from `maestro-protocol` types, backed by a
//! `Vec<Session>` instead of tmux. Use it whenever the subject under test is a
//! client — request building, error decoding, the catalog reducer, the proxy —
//! rather than session management itself. Tests that genuinely need tmux belong
//! in `maestro-e2e` against a real daemon.
#![deny(missing_debug_implementations)]
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::{Arc, Mutex};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get};
use axum::{Json, Router};
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION,
};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot};
use uuid::Uuid;
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
Session {
id,
cwd: cwd.into(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: Utc::now(),
current_cwd: None,
}
}
#[derive(Debug, Default)]
struct Inner {
sessions: Vec<Session>,
info: Option<DaemonInfo>,
/// When set, every route answers with this instead of its normal response.
forced_error: Option<(u16, ApiError)>,
}
#[derive(Clone)]
struct AppState {
inner: Arc<Mutex<Inner>>,
events: broadcast::Sender<CatalogEvent>,
}
impl std::fmt::Debug for AppState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppState").finish_non_exhaustive()
}
}
/// A running mock daemon. Shuts down when dropped.
#[derive(Debug)]
pub struct MockDaemon {
addr: SocketAddr,
state: AppState,
_shutdown: oneshot::Sender<()>,
}
impl MockDaemon {
pub async fn start() -> Self {
let state = AppState {
inner: Arc::new(Mutex::new(Inner::default())),
events: broadcast::channel(64).0,
};
let app = Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
.route(routes::GITROOT, get(git_root))
.route(routes::EVENTS, any(events_ws))
.route(routes::patterns::ATTACH, any(attach_ws))
.with_state(state.clone());
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.expect("bind");
let addr = listener.local_addr().expect("local_addr");
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
Self {
addr,
state,
_shutdown: tx,
}
}
pub fn addr(&self) -> SocketAddr {
self.addr
}
pub fn http_base(&self) -> String {
format!("http://{}", self.addr)
}
/// A client bound to this daemon.
pub fn client(&self) -> DaemonClient {
DaemonClient::new(self.http_base())
}
/// Add a session and announce it, as a real spawn would.
pub fn add_session(&self, session: Session) {
self.state
.inner
.lock()
.expect("poisoned")
.sessions
.push(session.clone());
let _ = self
.state
.events
.send(CatalogEvent::SessionAdded { session });
}
/// Remove a session and announce it.
pub fn remove_session(&self, id: Uuid) {
self.state
.inner
.lock()
.expect("poisoned")
.sessions
.retain(|s| s.id != id);
let _ = self.state.events.send(CatalogEvent::SessionRemoved { id });
}
pub fn set_info(&self, info: DaemonInfo) {
self.state.inner.lock().expect("poisoned").info = Some(info);
}
/// Make every route answer with this error, so clients' failure paths — and
/// their [`ApiError`] decoding — can be exercised.
pub fn force_error(&self, status: u16, code: &str, message: &str) {
self.state.inner.lock().expect("poisoned").forced_error =
Some((status, ApiError::new(code, message)));
}
pub fn clear_error(&self) {
self.state.inner.lock().expect("poisoned").forced_error = None;
}
pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len()
}
}
fn forced(state: &AppState) -> Option<Response> {
let guard = state.inner.lock().expect("poisoned");
guard.forced_error.as_ref().map(|(status, body)| {
(
StatusCode::from_u16(*status).expect("valid status"),
Json(body.clone()),
)
.into_response()
})
}
async fn list_sessions(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let sessions = s.inner.lock().expect("poisoned").sessions.clone();
Json(sessions).into_response()
}
async fn create_session(
State(s): State<AppState>,
Json(req): Json<CreateSessionRequest>,
) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let mut created = session(Uuid::new_v4(), &req.cwd.to_string_lossy(), &req.command);
created.env = req.env;
created.metadata = req.metadata;
s.inner
.lock()
.expect("poisoned")
.sessions
.push(created.clone());
let _ = s.events.send(CatalogEvent::SessionAdded {
session: created.clone(),
});
Json(created).into_response()
}
async fn kill_session(State(s): State<AppState>, Path(id): Path<Uuid>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let existed = {
let mut guard = s.inner.lock().expect("poisoned");
let before = guard.sessions.len();
guard.sessions.retain(|x| x.id != id);
guard.sessions.len() != before
};
if !existed {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
let _ = s.events.send(CatalogEvent::SessionRemoved { id });
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let info = s
.inner
.lock()
.expect("poisoned")
.info
.clone()
.unwrap_or_else(|| DaemonInfo {
hostname: "mock-host".into(),
version: PROTOCOL_VERSION.into(),
home_dir: "/home/mock".into(),
presets: Vec::new(),
});
Json(info).into_response()
}
async fn list_dir(State(s): State<AppState>, Query(q): Query<PathQuery>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
Json(DirListing {
path: q.path.into(),
entries: Vec::new(),
truncated: false,
})
.into_response()
}
async fn recent_dirs(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
Json(RecentDirs {
dirs: Vec::new(),
truncated: false,
})
.into_response()
}
async fn git_root(State(s): State<AppState>, Query(_q): Query<PathQuery>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
Json(GitRoot { root: None }).into_response()
}
/// Snapshot on connect, then live deltas — the same contract the daemon offers.
async fn events_ws(State(s): State<AppState>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(move |socket| events_loop(s, socket))
}
async fn events_loop(s: AppState, socket: WebSocket) {
let (mut sink, mut stream) = socket.split();
let mut rx = s.events.subscribe();
let snapshot = CatalogEvent::Snapshot {
sessions: s.inner.lock().expect("poisoned").sessions.clone(),
};
if sink
.send(Message::Text(
serde_json::to_string(&snapshot).expect("encode"),
))
.await
.is_err()
{
return;
}
loop {
tokio::select! {
ev = rx.recv() => {
let Ok(ev) = ev else { break };
let text = serde_json::to_string(&ev).expect("encode");
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
frame = stream.next() => {
match frame {
None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break,
Some(Ok(_)) => {}
}
}
}
}
}
/// Echoes whatever it is sent, so a client's PTY plumbing can be exercised.
async fn attach_ws(
State(s): State<AppState>,
Path(id): Path<Uuid>,
ws: WebSocketUpgrade,
) -> Response {
let known = s
.inner
.lock()
.expect("poisoned")
.sessions
.iter()
.any(|x| x.id == id);
if !known {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
ws.on_upgrade(|mut socket| async move {
while let Some(Ok(msg)) = socket.next().await {
match msg {
Message::Binary(b) => {
if socket.send(Message::Binary(b)).await.is_err() {
break;
}
}
Message::Text(t) => {
if socket.send(Message::Text(t)).await.is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
})
}
#[derive(Debug, serde::Deserialize)]
struct PathQuery {
#[serde(default)]
path: String,
}
+2 -2
View File
@@ -12,9 +12,8 @@ clap.workspace = true
crossterm.workspace = true
futures-util.workspace = true
maestro-config.workspace = true
maestro-protocol.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"] }
@@ -24,6 +23,7 @@ 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"] }
+286 -180
View File
@@ -16,7 +16,7 @@ use std::time::Duration;
use crossterm::event::{Event as CtEvent, EventStream};
use futures_util::StreamExt;
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CreateSessionRequest, DaemonClient};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use uuid::Uuid;
@@ -25,7 +25,7 @@ use crate::action::Action;
use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::emulators::SessionEmulators;
use crate::input::{canonicalise, ChordResult, ChordState, InputKey};
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, SpawnModal};
use crate::pane::{Pane, PaneStatus};
@@ -52,23 +52,35 @@ pub struct App {
spawn: SpawnModal,
help: HelpOverlay,
chord: ChordState,
daemon_base_url: String,
daemon: DaemonClient,
region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>,
http: reqwest::Client,
clicks: ClickTracker,
}
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
.field("daemon_base_url", &self.daemon_base_url)
.field("daemon", &self.daemon.base())
.field("state", &self.state)
.finish_non_exhaustive()
}
}
/// Something a keystroke decided to do that reaches outside `AppState`.
///
/// `dispatch` returns these instead of performing them, so every arm is
/// assertable without a daemon on the other end.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
Attach(Uuid),
Spawn(CreateSessionRequest),
Kill(Uuid),
Forward { id: Uuid, bytes: Vec<u8> },
}
impl App {
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon_base_url: String) -> Self {
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self {
let chord_timeout = cfg.chord_timeout;
let help = HelpOverlay {
lines: render_help_lines(&cfg),
@@ -81,13 +93,10 @@ impl App {
spawn: SpawnModal::default(),
help,
chord: ChordState::new(chord_timeout),
daemon_base_url,
daemon,
region: RegionMap::default(),
last_sent_size: HashMap::new(),
http: reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("reqwest client"),
clicks: ClickTracker::default(),
}
}
@@ -122,9 +131,12 @@ impl App {
let Some(key) = canonicalise(&ev, |c, r| map.region_at(c, r)) else {
return Ok(false);
};
let key = self.clicks.promote(key, std::time::Instant::now());
// Overlay intercept first.
if self.handle_overlay_input(&key).await == OverlayDecision::Consumed {
let (decision, effects) = self.handle_overlay_input(&key);
self.perform(effects).await;
if decision == OverlayDecision::Consumed {
return Ok(false);
}
@@ -136,52 +148,87 @@ impl App {
if matches!(act, Action::Quit) {
return Ok(true);
}
self.dispatch(act).await;
let effects = self.dispatch(act);
self.perform(effects).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{
self.forward_to_session(&key).await;
if let Some(effect) = self.forward_to_session(&key) {
self.perform(vec![effect]).await;
}
}
Ok(false)
}
}
}
async fn handle_overlay_input(&mut self, key: &InputKey) -> OverlayDecision {
use crate::overlays::SpawnInputResult;
match self.state.overlay.clone() {
Some(OverlayKind::Spawn) => {
match self.spawn.on_input(key) {
SpawnInputResult::Consumed => {}
SpawnInputResult::Cancel => {
self.state.overlay = None;
}
SpawnInputResult::Submit => {
self.submit_spawn().await;
/// Carry out what `dispatch` decided. The only place in `App` that talks to
/// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) {
for effect in effects {
match effect {
Effect::Attach(id) => self.attach(id),
Effect::Spawn(req) => self.submit_spawn(req).await,
Effect::Kill(id) => self.submit_kill(id).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
}
}
OverlayDecision::Consumed
}
Some(OverlayKind::KillConfirm(id)) => match key {
InputKey::Char { c: 'y', .. } => {
self.state.overlay = None;
self.submit_kill(id).await;
OverlayDecision::Consumed
}
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
self.state.overlay = None;
OverlayDecision::Consumed
}
_ => OverlayDecision::Consumed,
},
Some(OverlayKind::Help) => {
self.state.overlay = None;
OverlayDecision::Consumed
}
Some(OverlayKind::Toast(_)) | None => OverlayDecision::Pass,
}
}
async fn dispatch(&mut self, act: Action) {
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
use crate::overlays::SpawnInputResult;
match self.state.overlay().cloned() {
Some(OverlayKind::Spawn) => {
let mut effects = Vec::new();
match self.spawn.on_input(key) {
SpawnInputResult::Consumed => {}
SpawnInputResult::Cancel => {
self.state.dismiss_overlay();
}
SpawnInputResult::Submit => {
effects.push(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()),
command: self.spawn.command.value().to_string(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}));
}
}
(OverlayDecision::Consumed, effects)
}
Some(OverlayKind::KillConfirm(id)) => match key {
InputKey::Char { c: 'y', .. } => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, vec![Effect::Kill(id)])
}
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
}
_ => (OverlayDecision::Consumed, Vec::new()),
},
Some(OverlayKind::Help) => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
}
// A toast is informational: clear it, but let the keystroke go on
// and do its normal job rather than being swallowed.
Some(OverlayKind::Toast(_)) => {
self.state.dismiss_overlay();
(OverlayDecision::Pass, Vec::new())
}
None => (OverlayDecision::Pass, Vec::new()),
}
}
/// Fold a resolved action into `AppState` and report anything it wants done
/// outside it. Deliberately sync and side-effect-free.
fn dispatch(&mut self, act: Action) -> Vec<Effect> {
let mut effects = Vec::new();
match act {
Action::Noop | Action::Quit => {}
Action::EnterInsert => self.state.mode = Mode::Insert,
@@ -203,7 +250,11 @@ impl App {
self.state.focus = Focus::Sidebar;
}
Action::NewTab => self.state.new_tab(),
Action::CloseTab => self.state.close_tab(),
Action::CloseTab => {
if let Some(sid) = self.state.close_tab() {
self.release_session(sid);
}
}
Action::NextTab => self.state.next_tab(),
Action::PrevTab => self.state.prev_tab(),
Action::GotoTab(n) => self.state.goto_tab(n),
@@ -212,37 +263,50 @@ impl App {
self.help = HelpOverlay {
lines: render_help_lines(&self.cfg),
};
self.state.overlay = Some(OverlayKind::Help);
self.state.show_overlay(OverlayKind::Help);
}
Action::OpenSpawnModal => {
// Rebuild spawn modal against the current catalog so the
// daemon dropdown is fresh.
let snap = self.catalog.snapshot();
self.spawn = SpawnModal {
daemons: snap.daemons,
daemon_idx: 0,
cwd: String::new(),
command: String::new(),
cwd: Default::default(),
command: Default::default(),
focused_field: Default::default(),
};
self.state.overlay = Some(OverlayKind::Spawn);
self.state.show_overlay(OverlayKind::Spawn);
}
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
if self.emulators.get(sid).is_none() {
let ws_url = format!("{}/api/attach/{sid}", ws_base(&self.daemon_base_url));
let handle = SessionTask::spawn(ws_url, 24, 80);
self.emulators.insert(sid, handle);
}
effects.push(Effect::Attach(sid));
self.state.attach_to_focused(sid);
}
}
Action::KillHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
self.state.overlay = Some(OverlayKind::KillConfirm(sid));
self.state.show_overlay(OverlayKind::KillConfirm(sid));
}
}
}
effects
}
/// Open a stream for `sid` if one isn't already running.
fn attach(&mut self, sid: Uuid) {
if self.emulators.get(sid).is_some() {
return;
}
match self.daemon.attach_url(sid) {
Ok(ws_url) => {
let handle = SessionTask::spawn(ws_url, 24, 80);
self.emulators.insert(sid, handle);
}
Err(e) => self.state.toast(format!("attach failed: {e}")),
}
}
/// Tear down the emulator for `sid`, aborting its WebSocket task and
/// dropping its vt100 parser.
fn release_session(&mut self, sid: Uuid) {
self.emulators.remove(sid);
self.last_sent_size.remove(&sid);
}
/// Return the session under the sidebar highlight, falling back to the
@@ -255,13 +319,11 @@ impl App {
snap.sessions.first().map(|s| s.id)
}
async fn forward_to_session(&mut self, key: &InputKey) {
let Some(sid) = self.state.active_tab().pane else {
return;
};
let Some(handle) = self.emulators.get(sid) else {
return;
};
/// Encode a keystroke for the focused pane. `None` when there is nothing
/// attached to send it to.
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
let sid = self.state.active_tab().pane?;
self.emulators.get(sid)?;
let bytes = match key {
InputKey::Char { c, ctrl, alt, .. } => {
let mut out = Vec::with_capacity(8);
@@ -279,65 +341,34 @@ impl App {
}
InputKey::Enter => b"\r".to_vec(),
InputKey::Tab => b"\t".to_vec(),
InputKey::BackTab => b"\x1b[Z".to_vec(),
InputKey::Backspace => vec![0x7f],
InputKey::Esc => vec![0x1b],
InputKey::Up => b"\x1b[A".to_vec(),
InputKey::Down => b"\x1b[B".to_vec(),
InputKey::Right => b"\x1b[C".to_vec(),
InputKey::Left => b"\x1b[D".to_vec(),
InputKey::Mouse { .. } => return,
InputKey::Home => b"\x1b[H".to_vec(),
InputKey::End => b"\x1b[F".to_vec(),
InputKey::Delete => b"\x1b[3~".to_vec(),
InputKey::Paste(_) | InputKey::Mouse { .. } => return None,
};
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
Some(Effect::Forward { id: sid, bytes })
}
async fn submit_spawn(&mut self) {
let daemon_id = match self.spawn.daemons.get(self.spawn.daemon_idx) {
Some(d) => d.daemon_id.clone(),
None => {
self.state.overlay = Some(OverlayKind::Toast("spawn: pick a daemon first".into()));
return;
}
};
let cwd = std::path::PathBuf::from(self.spawn.cwd.clone());
let req = CreateSessionRequest {
daemon_id,
cwd,
command: self.spawn.command.clone(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
};
let url = format!("{}/api/sessions", self.daemon_base_url);
match self.http.post(&url).json(&req).send().await {
Ok(res) if res.status().is_success() => {
self.state.overlay = None;
}
Ok(res) => {
let status = res.status();
self.state.overlay =
Some(OverlayKind::Toast(format!("spawn failed: HTTP {status}")));
}
Err(e) => {
self.state.overlay = Some(OverlayKind::Toast(format!("spawn failed: {e}")));
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
match self.daemon.create_session(&req).await {
Ok(_) => {
self.state.dismiss_overlay();
}
Err(e) => self.state.toast(format!("spawn failed: {e}")),
}
}
async fn submit_kill(&mut self, sid: Uuid) {
let url = format!("{}/api/sessions/{sid}", self.daemon_base_url);
match self.http.delete(&url).send().await {
Ok(res) if res.status().is_success() => {
self.emulators.remove(sid);
self.last_sent_size.remove(&sid);
}
Ok(res) => {
let status = res.status();
self.state.overlay =
Some(OverlayKind::Toast(format!("kill failed: HTTP {status}")));
}
Err(e) => {
self.state.overlay = Some(OverlayKind::Toast(format!("kill failed: {e}")));
}
match self.daemon.kill_session(sid).await {
Ok(()) => self.release_session(sid),
Err(e) => self.state.toast(format!("kill failed: {e}")),
}
}
@@ -348,7 +379,6 @@ impl App {
let snap = self.catalog.snapshot();
let attached: HashSet<Uuid> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
let sidebar = Sidebar {
daemons: snap.daemons.clone(),
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight,
attached,
@@ -412,27 +442,11 @@ impl App {
}
}
/// Order the sidebar walks for `j`/`k`: each daemon's sessions, in the same
/// order [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight
/// movement in visual sync with what the user sees.
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
/// visual sync with what the user sees.
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
let mut out = Vec::with_capacity(snap.sessions.len());
for d in &snap.daemons {
for s in snap.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
out.push(s.id);
}
}
out
}
fn ws_base(http: &str) -> String {
if let Some(rest) = http.strip_prefix("http://") {
format!("ws://{rest}")
} else if let Some(rest) = http.strip_prefix("https://") {
format!("wss://{rest}")
} else {
http.to_string()
}
snap.sessions.iter().map(|s| s.id).collect()
}
/// Build the lines shown by the help overlay from the live keymap, so the
@@ -467,74 +481,166 @@ mod tests {
fn make_app() -> App {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
App::new(cfg, CatalogStore::default(), "http://127.0.0.1:7070".into())
App::new(
cfg,
CatalogStore::default(),
DaemonClient::new("http://127.0.0.1:7070"),
)
}
#[tokio::test]
async fn enter_insert_then_leave() {
#[test]
fn enter_insert_then_leave() {
let mut app = make_app();
assert_eq!(app.state.mode, Mode::Normal);
app.dispatch(Action::EnterInsert).await;
app.dispatch(Action::EnterInsert);
assert_eq!(app.state.mode, Mode::Insert);
app.dispatch(Action::LeaveInsert).await;
app.dispatch(Action::LeaveInsert);
assert_eq!(app.state.mode, Mode::Normal);
}
#[tokio::test]
async fn new_tab_action_extends_tabs() {
#[test]
fn new_tab_action_extends_tabs() {
let mut app = make_app();
app.dispatch(Action::NewTab).await;
app.dispatch(Action::NewTab).await;
app.dispatch(Action::NewTab);
app.dispatch(Action::NewTab);
assert_eq!(app.state.tabs.len(), 3);
app.dispatch(Action::CloseTab).await;
app.dispatch(Action::CloseTab);
assert_eq!(app.state.tabs.len(), 2);
}
#[tokio::test]
async fn show_help_opens_overlay() {
#[test]
fn show_help_opens_overlay() {
let mut app = make_app();
app.dispatch(Action::ShowHelp).await;
assert!(matches!(app.state.overlay, Some(OverlayKind::Help)));
app.dispatch(Action::ShowHelp);
assert!(matches!(app.state.overlay(), Some(OverlayKind::Help)));
}
#[tokio::test]
async fn open_spawn_modal_seeds_daemon_list() {
use maestro_protocol::{DaemonInfo, RegistryEvent};
#[test]
fn open_spawn_modal_sets_overlay() {
let mut app = make_app();
let d = DaemonInfo {
daemon_id: "konishi".into(),
hostname: "konishi.lan".into(),
version: "test".into(),
daemon_url: "ws://k:7070".into(),
home_dir: String::new(),
};
app.catalog.apply_event(RegistryEvent::Snapshot {
daemons: vec![d.clone()],
sessions: vec![],
});
app.dispatch(Action::OpenSpawnModal).await;
assert!(matches!(app.state.overlay, Some(OverlayKind::Spawn)));
assert_eq!(app.spawn.daemons.len(), 1);
assert_eq!(app.spawn.daemons[0].daemon_id, "konishi");
app.dispatch(Action::OpenSpawnModal);
assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn)));
}
#[tokio::test]
async fn kill_highlighted_opens_confirm() {
/// Seed the catalog with one session and highlight it.
fn app_with_highlighted_session(sid: Uuid) -> App {
use chrono::Utc;
use maestro_protocol::{DaemonInfo, RegistryEvent, Session, SessionStatus};
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let mut app = make_app();
let sid = Uuid::from_u128(7);
app.catalog.apply_event(RegistryEvent::Snapshot {
daemons: vec![DaemonInfo {
daemon_id: "k".into(),
hostname: "k".into(),
version: "t".into(),
daemon_url: "ws://k".into(),
home_dir: String::new(),
}],
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
metadata: Default::default(),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: Utc::now(),
current_cwd: None,
}],
});
app.dispatch(Action::SidebarNext);
app
}
#[test]
fn attach_highlighted_asks_for_an_attach() {
let sid = Uuid::from_u128(11);
let mut app = app_with_highlighted_session(sid);
assert_eq!(
app.dispatch(Action::AttachHighlighted),
vec![Effect::Attach(sid)]
);
assert_eq!(app.state.active_tab().pane, Some(sid));
}
#[test]
fn confirming_a_kill_asks_for_the_kill() {
let sid = Uuid::from_u128(12);
let mut app = app_with_highlighted_session(sid);
app.dispatch(Action::KillHighlighted);
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
c: 'y',
ctrl: false,
alt: false,
shift: false,
});
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::Kill(sid)]);
assert!(app.state.overlay().is_none());
}
#[test]
fn declining_a_kill_asks_for_nothing() {
let sid = Uuid::from_u128(13);
let mut app = app_with_highlighted_session(sid);
app.dispatch(Action::KillHighlighted);
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
assert!(app.state.overlay().is_none());
}
#[test]
fn submitting_the_spawn_modal_carries_the_typed_fields() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work");
app.spawn.command = crate::text_input::TextInput::with_value("claude");
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
match effects.as_slice() {
[Effect::Spawn(req)] => {
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
assert_eq!(req.command, "claude");
}
other => panic!("expected one Spawn effect, got {other:?}"),
}
}
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
/// failure message stayed on screen until another overlay replaced it.
#[test]
fn a_toast_is_dismissed_by_the_next_key_without_eating_it() {
let mut app = make_app();
app.state.toast("spawn failed: nope");
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
c: 'j',
ctrl: false,
alt: false,
shift: false,
});
assert_eq!(
decision,
OverlayDecision::Pass,
"the keystroke should still reach the keymap"
);
assert!(effects.is_empty());
assert!(app.state.overlay().is_none(), "the toast must be gone");
}
#[test]
fn forwarding_is_a_no_op_without_an_attached_pane() {
let mut app = make_app();
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
#[test]
fn kill_highlighted_opens_confirm() {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let mut app = make_app();
let sid = Uuid::from_u128(7);
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
id: sid,
daemon_id: "k".into(),
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
@@ -548,7 +654,7 @@ mod tests {
current_cwd: None,
}],
});
app.dispatch(Action::KillHighlighted).await;
assert!(matches!(app.state.overlay, Some(OverlayKind::KillConfirm(s)) if s == sid));
app.dispatch(Action::KillHighlighted);
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
}
}
+30 -118
View File
@@ -1,10 +1,10 @@
//! `CatalogStore` — the TUI's view of the daemons-and-sessions tree.
//! `CatalogStore` — the TUI's view of the session list.
//!
//! Backed entirely by the daemon's `WS /api/events` stream:
//!
//! 1. The first frame after connect is a [`RegistryEvent::Snapshot`] — that's
//! 1. The first frame after connect is a [`CatalogEvent::Snapshot`] — that's
//! the bootstrap. No separate REST round-trip needed.
//! 2. Subsequent frames are deltas (`DaemonAdded`, `SessionAdded`, …) that we
//! 2. Subsequent frames are deltas (`SessionAdded`, `SessionUpdated`, …) that we
//! apply atomically and bump a monotonic version counter.
//! 3. On WS disconnect: exponential backoff (1s → 30s, ±25% jitter); the next
//! connect resends a fresh `Snapshot` so we converge without needing
@@ -21,7 +21,7 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
use futures_util::StreamExt;
use maestro_protocol::{DaemonInfo, RegistryEvent, Session};
use maestro_protocol::{CatalogEvent, Session};
use tokio::sync::Notify;
use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
@@ -31,7 +31,6 @@ use uuid::Uuid;
/// needed.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CatalogSnapshot {
pub daemons: Vec<DaemonInfo>,
pub sessions: Vec<Session>,
pub version: u64,
}
@@ -49,7 +48,6 @@ struct Inner {
#[derive(Debug, Default)]
struct State {
daemons: HashMap<String, DaemonInfo>,
sessions: HashMap<Uuid, Session>,
/// Bumped on every applied event. Wraps at u64::MAX, which is fine
/// because comparisons are equality, not ordering.
@@ -61,33 +59,20 @@ struct State {
impl CatalogStore {
/// Apply one event atomically. Used by tests directly and by the WS loop.
pub fn apply_event(&self, ev: RegistryEvent) {
pub fn apply_event(&self, ev: CatalogEvent) {
let mut s = self.inner.state.write().expect("catalog rwlock poisoned");
match ev {
RegistryEvent::Snapshot { daemons, sessions } => {
s.daemons = daemons
.into_iter()
.map(|d| (d.daemon_id.clone(), d))
.collect();
CatalogEvent::Snapshot { sessions } => {
s.sessions = sessions.into_iter().map(|x| (x.id, x)).collect();
s.bootstrapped = true;
}
RegistryEvent::DaemonAdded { daemon } => {
s.daemons.insert(daemon.daemon_id.clone(), daemon);
}
RegistryEvent::DaemonRemoved { daemon_id } => {
s.daemons.remove(&daemon_id);
// Drop sessions owned by the gone daemon — server will
// re-add them if it reappears.
s.sessions.retain(|_, sess| sess.daemon_id != daemon_id);
}
RegistryEvent::SessionAdded { session } | RegistryEvent::SessionUpdated { session } => {
CatalogEvent::SessionAdded { session } | CatalogEvent::SessionUpdated { session } => {
s.sessions.insert(session.id, session);
}
RegistryEvent::SessionRemoved { daemon_id: _, id } => {
CatalogEvent::SessionRemoved { id } => {
s.sessions.remove(&id);
}
RegistryEvent::Lagged { skipped } => {
CatalogEvent::Lagged { skipped } => {
tracing::warn!(
skipped,
"catalog events stream lagged; resync via reconnect"
@@ -101,15 +86,12 @@ impl CatalogStore {
self.inner.notify.notify_waiters();
}
/// Cheap O(daemons + sessions) read of current state.
/// Cheap O(sessions) read of current state.
pub fn snapshot(&self) -> CatalogSnapshot {
let s = self.inner.state.read().expect("catalog rwlock poisoned");
let mut daemons: Vec<DaemonInfo> = s.daemons.values().cloned().collect();
daemons.sort_by(|a, b| a.daemon_id.cmp(&b.daemon_id));
let mut sessions: Vec<Session> = s.sessions.values().cloned().collect();
sessions.sort_by_key(|x| x.id);
CatalogSnapshot {
daemons,
sessions,
version: s.version,
}
@@ -141,7 +123,7 @@ impl CatalogStore {
}
}
/// Long-running task: connect to `ws_url`, parse `RegistryEvent` frames,
/// Long-running task: connect to `ws_url`, parse [`CatalogEvent`] frames,
/// apply them. Reconnects with exponential backoff on disconnect. Never
/// returns — spawn it on a Tokio task.
pub async fn run_events_loop(self, ws_url: String) {
@@ -154,7 +136,7 @@ impl CatalogStore {
while let Some(frame) = stream.next().await {
match frame {
Ok(Message::Text(text)) => {
match serde_json::from_str::<RegistryEvent>(&text) {
match serde_json::from_str::<CatalogEvent>(&text) {
Ok(ev) => self.apply_event(ev),
Err(e) => {
tracing::warn!(error = ?e, "catalog: bad event frame");
@@ -197,22 +179,11 @@ fn jitter(base: Duration) -> Duration {
mod tests {
use super::*;
use chrono::Utc;
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
use maestro_protocol::{Session, SessionStatus};
fn daemon(id: &str) -> DaemonInfo {
DaemonInfo {
daemon_id: id.into(),
hostname: format!("{id}.lan"),
version: "test".into(),
daemon_url: format!("ws://{id}.lan:7070"),
home_dir: String::new(),
}
}
fn session(id: u128, daemon_id: &str, command: &str) -> Session {
fn session(id: u128, command: &str) -> Session {
Session {
id: Uuid::from_u128(id),
daemon_id: daemon_id.into(),
command: command.into(),
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
@@ -222,7 +193,6 @@ mod tests {
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
@@ -230,82 +200,39 @@ mod tests {
#[test]
fn snapshot_event_replaces_state() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("konishi")],
sessions: vec![session(1, "konishi", "a")],
store.apply_event(CatalogEvent::Snapshot {
sessions: vec![session(1, "a")],
});
let snap = store.snapshot();
assert_eq!(snap.daemons.len(), 1);
assert_eq!(snap.sessions.len(), 1);
assert_eq!(snap.version, 1);
assert!(store.is_bootstrapped());
// A second snapshot wipes out the old state.
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("yamato")],
sessions: vec![],
});
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
let snap = store.snapshot();
assert_eq!(snap.daemons.len(), 1);
assert_eq!(snap.daemons[0].daemon_id, "yamato");
assert!(snap.sessions.is_empty());
assert_eq!(snap.version, 2);
}
#[test]
fn daemon_added_then_removed() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![],
sessions: vec![],
});
store.apply_event(RegistryEvent::DaemonAdded {
daemon: daemon("konishi"),
});
assert_eq!(store.snapshot().daemons.len(), 1);
store.apply_event(RegistryEvent::DaemonRemoved {
daemon_id: "konishi".into(),
});
assert!(store.snapshot().daemons.is_empty());
}
#[test]
fn daemon_removed_evicts_its_sessions() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("konishi"), daemon("yamato")],
sessions: vec![session(1, "konishi", "a"), session(2, "yamato", "b")],
});
store.apply_event(RegistryEvent::DaemonRemoved {
daemon_id: "konishi".into(),
});
let snap = store.snapshot();
assert_eq!(snap.sessions.len(), 1);
assert_eq!(snap.sessions[0].daemon_id, "yamato");
}
#[test]
fn session_added_updated_removed() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("konishi")],
sessions: vec![],
});
store.apply_event(RegistryEvent::SessionAdded {
session: session(7, "konishi", "vim"),
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
store.apply_event(CatalogEvent::SessionAdded {
session: session(7, "vim"),
});
assert_eq!(store.snapshot().sessions.len(), 1);
assert_eq!(store.snapshot().sessions[0].command, "vim");
let mut updated = session(7, "konishi", "claude");
let mut updated = session(7, "claude");
updated.status = SessionStatus::Dead;
store.apply_event(RegistryEvent::SessionUpdated { session: updated });
store.apply_event(CatalogEvent::SessionUpdated { session: updated });
let snap = store.snapshot();
assert_eq!(snap.sessions[0].command, "claude");
assert!(matches!(snap.sessions[0].status, SessionStatus::Dead));
store.apply_event(RegistryEvent::SessionRemoved {
daemon_id: "konishi".into(),
store.apply_event(CatalogEvent::SessionRemoved {
id: Uuid::from_u128(7),
});
assert!(store.snapshot().sessions.is_empty());
@@ -314,14 +241,12 @@ mod tests {
#[test]
fn lagged_event_doesnt_blow_up_state() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("konishi")],
sessions: vec![session(1, "konishi", "a")],
store.apply_event(CatalogEvent::Snapshot {
sessions: vec![session(1, "a")],
});
let before = store.snapshot();
store.apply_event(RegistryEvent::Lagged { skipped: 42 });
store.apply_event(CatalogEvent::Lagged { skipped: 42 });
let after = store.snapshot();
assert_eq!(after.daemons, before.daemons);
assert_eq!(after.sessions, before.sessions);
assert_eq!(after.version, before.version + 1);
}
@@ -329,17 +254,10 @@ mod tests {
#[test]
fn snapshot_is_sorted() {
let store = CatalogStore::default();
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![daemon("yamato"), daemon("konishi"), daemon("aurora")],
sessions: vec![
session(3, "aurora", "c"),
session(1, "konishi", "a"),
session(2, "yamato", "b"),
],
store.apply_event(CatalogEvent::Snapshot {
sessions: vec![session(3, "c"), session(1, "a"), session(2, "b")],
});
let snap = store.snapshot();
let ids: Vec<_> = snap.daemons.iter().map(|d| d.daemon_id.as_str()).collect();
assert_eq!(ids, vec!["aurora", "konishi", "yamato"]);
let session_ids: Vec<_> = snap.sessions.iter().map(|s| s.id.as_u128()).collect();
assert_eq!(session_ids, vec![1, 2, 3]);
}
@@ -353,10 +271,7 @@ mod tests {
});
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(!handle.is_finished());
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![],
sessions: vec![],
});
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
// Give the waker a chance.
tokio::time::timeout(Duration::from_millis(200), handle)
.await
@@ -372,10 +287,7 @@ mod tests {
tokio::spawn(async move { s.changed().await })
};
tokio::time::sleep(Duration::from_millis(10)).await;
store.apply_event(RegistryEvent::Snapshot {
daemons: vec![],
sessions: vec![],
});
store.apply_event(CatalogEvent::Snapshot { sessions: vec![] });
tokio::time::timeout(Duration::from_millis(200), waiter)
.await
.expect("changed did not fire")
+239 -6
View File
@@ -27,11 +27,19 @@ pub enum InputKey {
Esc,
Enter,
Tab,
/// Shift-Tab. crossterm reports it as its own key code, not a modified Tab.
BackTab,
Backspace,
Up,
Down,
Left,
Right,
Home,
End,
Delete,
/// Bracketed paste: crossterm delivers the whole payload as one event, so
/// text fields can insert it as a single operation.
Paste(String),
Mouse {
kind: MouseKind,
region: Region,
@@ -54,11 +62,9 @@ pub enum MouseKind {
/// NAP-280); for now only the enum is needed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region {
Sidebar,
SidebarRow,
Tab,
Pane,
SplitDivider,
Modal,
Unknown,
}
@@ -120,11 +126,18 @@ impl InputKey {
InputKey::Esc => "esc".into(),
InputKey::Enter => "enter".into(),
InputKey::Tab => "tab".into(),
InputKey::BackTab => "backtab".into(),
InputKey::Backspace => "backspace".into(),
InputKey::Up => "up".into(),
InputKey::Down => "down".into(),
InputKey::Left => "left".into(),
InputKey::Right => "right".into(),
InputKey::Home => "home".into(),
InputKey::End => "end".into(),
InputKey::Delete => "delete".into(),
// Pastes are not bindable; the canonical form exists only to
// satisfy exhaustiveness and never round-trips through `parse`.
InputKey::Paste(_) => "paste".into(),
InputKey::Mouse { kind, region, .. } => {
let k = match kind {
MouseKind::Click => "click",
@@ -134,11 +147,9 @@ impl InputKey {
MouseKind::ScrollDown => "scrolldown",
};
let r = match region {
Region::Sidebar => "sidebar",
Region::SidebarRow => "sidebar-row",
Region::Tab => "tab",
Region::Pane => "pane",
Region::SplitDivider => "split-divider",
Region::Modal => "modal",
Region::Unknown => "unknown",
};
@@ -168,11 +179,9 @@ impl InputKey {
other => return Err(InputParseError::UnknownMouseKind(other.into())),
};
let region = match r {
"sidebar" => Region::Sidebar,
"sidebar-row" => Region::SidebarRow,
"tab" => Region::Tab,
"pane" => Region::Pane,
"split-divider" => Region::SplitDivider,
"modal" => Region::Modal,
"unknown" => Region::Unknown,
other => return Err(InputParseError::UnknownRegion(other.into())),
@@ -188,11 +197,15 @@ impl InputKey {
"esc" => return Ok(InputKey::Esc),
"enter" => return Ok(InputKey::Enter),
"tab" => return Ok(InputKey::Tab),
"backtab" => return Ok(InputKey::BackTab),
"backspace" => return Ok(InputKey::Backspace),
"up" => return Ok(InputKey::Up),
"down" => return Ok(InputKey::Down),
"left" => return Ok(InputKey::Left),
"right" => return Ok(InputKey::Right),
"home" => return Ok(InputKey::Home),
"end" => return Ok(InputKey::End),
"delete" => return Ok(InputKey::Delete),
_ => {}
}
// Modifier chain: ctrl-, alt-, shift- prefixes, then a single char.
@@ -258,14 +271,19 @@ where
KeyCode::Esc => InputKey::Esc,
KeyCode::Enter => InputKey::Enter,
KeyCode::Tab => InputKey::Tab,
KeyCode::BackTab => InputKey::BackTab,
KeyCode::Backspace => InputKey::Backspace,
KeyCode::Up => InputKey::Up,
KeyCode::Down => InputKey::Down,
KeyCode::Left => InputKey::Left,
KeyCode::Right => InputKey::Right,
KeyCode::Home => InputKey::Home,
KeyCode::End => InputKey::End,
KeyCode::Delete => InputKey::Delete,
_ => return None,
})
}
CtEvent::Paste(text) => Some(InputKey::Paste(text.clone())),
CtEvent::Mouse(MouseEvent {
kind, column, row, ..
}) => {
@@ -288,6 +306,63 @@ where
}
}
/// How long after a click a second click at the same cell counts as a
/// double-click. Matches the common desktop default.
pub const DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(400);
/// Remembers the last click so a second one at the same cell can be promoted to
/// [`MouseKind::DblClick`].
///
/// Kept out of [`canonicalise`], which stays a pure function of one event.
#[derive(Debug, Default)]
pub struct ClickTracker {
last: Option<(u16, u16, Instant)>,
}
impl ClickTracker {
pub fn new() -> Self {
Self::default()
}
/// Upgrade a `Click` to a `DblClick` when it *immediately* follows another
/// click at the same cell inside [`DOUBLE_CLICK_WINDOW`].
///
/// Anything else passes through and clears the pending click: two clicks
/// with a drag or a keystroke between them are two clicks, however fast.
///
/// A promoted click also resets the tracker, so three rapid clicks are a
/// click then a double-click, not two double-clicks.
pub fn promote(&mut self, key: InputKey, now: Instant) -> InputKey {
let InputKey::Mouse {
kind: MouseKind::Click,
region,
col,
row,
} = key
else {
self.last = None;
return key;
};
let is_double = matches!(
self.last,
Some((c, r, at))
if c == col && r == row && now.duration_since(at) <= DOUBLE_CLICK_WINDOW
);
if is_double {
self.last = None;
return InputKey::Mouse {
kind: MouseKind::DblClick,
region,
col,
row,
};
}
self.last = Some((col, row, now));
key
}
}
/// Result of feeding one key into [`ChordState`]. `Pending` = held because the
/// current sequence is a strict prefix of some known binding; `Resolved` =
/// dispatch this canonical string against the keymap.
@@ -358,6 +433,151 @@ impl ChordState {
mod tests {
use super::*;
fn click(col: u16, row: u16) -> InputKey {
InputKey::Mouse {
kind: MouseKind::Click,
region: Region::SidebarRow,
col,
row,
}
}
/// The shipped default binds `mouse:dblclick(sidebar-row)`. Nothing could
/// produce a DblClick, so that binding could never fire.
#[test]
fn a_second_click_at_the_same_cell_becomes_a_double_click() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
assert_eq!(t.promote(click(3, 4), t0), click(3, 4));
assert_eq!(
t.promote(click(3, 4), t0 + Duration::from_millis(100)),
InputKey::Mouse {
kind: MouseKind::DblClick,
region: Region::SidebarRow,
col: 3,
row: 4,
}
);
}
#[test]
fn a_slow_second_click_stays_a_click() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(3, 4), t0);
let late = t0 + DOUBLE_CLICK_WINDOW + Duration::from_millis(1);
assert_eq!(t.promote(click(3, 4), late), click(3, 4));
}
#[test]
fn a_second_click_elsewhere_stays_a_click() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(3, 4), t0);
assert_eq!(
t.promote(click(9, 4), t0 + Duration::from_millis(10)),
click(9, 4)
);
}
/// Three fast clicks are click, double-click, click — not two doubles.
#[test]
fn a_promoted_click_resets_the_tracker() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(1, 1), t0);
let second = t.promote(click(1, 1), t0 + Duration::from_millis(50));
assert!(matches!(
second,
InputKey::Mouse {
kind: MouseKind::DblClick,
..
}
));
let third = t.promote(click(1, 1), t0 + Duration::from_millis(100));
assert_eq!(third, click(1, 1));
}
#[test]
fn non_click_events_pass_through_untouched() {
let mut t = ClickTracker::new();
assert_eq!(t.promote(InputKey::Enter, Instant::now()), InputKey::Enter);
}
/// A drag between two clicks means the user dragged, not double-clicked —
/// even at the same cell and well inside the window.
#[test]
fn a_drag_between_two_clicks_breaks_the_double() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(3, 4), t0);
let drag = InputKey::Mouse {
kind: MouseKind::Drag,
region: Region::SidebarRow,
col: 3,
row: 4,
};
assert_eq!(
t.promote(drag.clone(), t0 + Duration::from_millis(20)),
drag
);
assert_eq!(
t.promote(click(3, 4), t0 + Duration::from_millis(40)),
click(3, 4),
"a drag must clear the pending click"
);
}
#[test]
fn a_keystroke_between_two_clicks_breaks_the_double() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(3, 4), t0);
t.promote(InputKey::Enter, t0 + Duration::from_millis(20));
assert_eq!(
t.promote(click(3, 4), t0 + Duration::from_millis(40)),
click(3, 4)
);
}
/// A scroll also counts as an intervening event.
#[test]
fn a_scroll_between_two_clicks_breaks_the_double() {
let mut t = ClickTracker::new();
let t0 = Instant::now();
t.promote(click(3, 4), t0);
let scroll = InputKey::Mouse {
kind: MouseKind::ScrollUp,
region: Region::SidebarRow,
col: 3,
row: 4,
};
t.promote(scroll, t0 + Duration::from_millis(20));
assert_eq!(
t.promote(click(3, 4), t0 + Duration::from_millis(40)),
click(3, 4)
);
}
#[test]
fn back_tab_round_trips_through_the_canonical_form() {
assert_eq!(InputKey::BackTab.canonical(), "backtab");
assert_eq!(InputKey::parse("backtab").unwrap(), InputKey::BackTab);
}
/// The parser must not accept region names the renderer cannot produce.
#[test]
fn unproducible_region_names_are_rejected() {
assert!(InputKey::parse("mouse:click(sidebar)").is_err());
assert!(InputKey::parse("mouse:click(split-divider)").is_err());
assert!(InputKey::parse("mouse:click(sidebar-row)").is_ok());
}
#[test]
fn canonical_round_trips_simple_chars() {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
@@ -377,12 +597,25 @@ mod tests {
"down",
"left",
"right",
"home",
"end",
"delete",
] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
/// A bracketed paste must arrive as one key carrying the whole payload,
/// not a burst of per-character keys.
#[test]
fn canonicalise_paste_event_carries_the_whole_payload() {
use crossterm::event::Event as CtEvent;
let ev = CtEvent::Paste("/srv/work/maestro".into());
let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap();
assert_eq!(k, InputKey::Paste("/srv/work/maestro".into()));
}
#[test]
fn canonical_round_trips_mouse() {
let s = "mouse:click(pane)";
+40 -33
View File
@@ -1,7 +1,4 @@
//! `maestro tui` — terminal UI client over the local daemon's `/api/*` surface.
//!
//! Design: `docs/superpowers/specs/2026-05-28-maestro-tui-design.md`
//! Implementation: `docs/superpowers/plans/2026-05-28-maestro-tui.md`
#![deny(missing_debug_implementations)]
@@ -21,10 +18,12 @@ pub mod sidebar;
pub mod state;
pub mod tabs;
pub mod terminal;
pub mod text_input;
use std::path::PathBuf;
use std::time::Duration;
use maestro_protocol::DaemonClient;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
@@ -40,14 +39,14 @@ pub struct RunConfig {
/// No trailing slash.
pub daemon_url: String,
/// Explicit `--config <path>` override from the umbrella binary's global
/// flag. When `None`, [`maestro_config::resolve_config_path`] picks the
/// flag. When `None`, [`maestro_config::default_config_path`] picks the
/// default (`$MAESTRO_CONFIG` or `~/.config/maestro/config.toml`).
pub config_path: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
pub enum TuiError {
#[error("daemon unreachable at {0}; try `maestro daemon configure` or check `systemctl --user status maestro-daemon`")]
#[error("daemon unreachable at {0}; check `systemctl --user status maestro-daemon`")]
DaemonUnreachable(String),
#[error("config: {0}")]
Config(String),
@@ -65,23 +64,32 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
let base_url = cfg.daemon_url.trim_end_matches('/').to_string();
// Health check — fail fast if no daemon is reachable.
health_check(&base_url).await?;
health_check(&DaemonClient::with_timeouts(
&base_url,
HEALTH_TIMEOUT,
HEALTH_TIMEOUT,
))
.await?;
let daemon = DaemonClient::with_timeouts(&base_url, REQUEST_TIMEOUT, REQUEST_TIMEOUT);
// Load runtime config: explicit `--config <path>` wins, then
// `$MAESTRO_CONFIG`, then the platform default. Missing file is fine —
// we fall back to shipped defaults.
let cfg_path = cfg.config_path.clone().map(Ok).unwrap_or_else(|| {
maestro_config::resolve_config_path().map_err(|e| TuiError::Config(e.to_string()))
maestro_config::default_config_path().map_err(|e| TuiError::Config(e.to_string()))
});
let cfg_file = match cfg_path {
Ok(path) => maestro_config::MaestroConfigFile::load_or_default(&path)
Ok(path) => maestro_config::MaestroConfigFile::load(&path)
.map_err(|e| TuiError::Config(e.to_string()))?,
Err(_) => maestro_config::MaestroConfigFile::default(),
};
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
let catalog = CatalogStore::default();
let ws_url = format!("{}/api/events", ws_base(&base_url));
let ws_url = daemon
.events_url()
.map_err(|e| TuiError::Config(e.to_string()))?;
let bg_catalog = catalog.clone();
tokio::spawn(async move { bg_catalog.run_events_loop(ws_url).await });
@@ -95,33 +103,32 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
let backend = CrosstermBackend::new(std::io::stdout());
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
let app = App::new(tui_cfg, catalog, base_url);
let app = App::new(tui_cfg, catalog, daemon);
app.run(&mut terminal).await
}
async fn health_check(base_url: &str) -> Result<(), TuiError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.build()
.expect("reqwest client");
let url = format!("{base_url}/api/web/info");
for _ in 0..3 {
if let Ok(res) = client.get(&url).send().await {
if res.status().is_success() {
return Ok(());
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(TuiError::DaemonUnreachable(base_url.to_string()))
}
/// Startup probe budget, applied to connect *and* the whole request: the user is
/// staring at a terminal, so an unreachable daemon must fail in under a second.
const HEALTH_TIMEOUT: Duration = Duration::from_millis(500);
fn ws_base(http: &str) -> String {
if let Some(rest) = http.strip_prefix("http://") {
format!("ws://{rest}")
} else if let Some(rest) = http.strip_prefix("https://") {
format!("wss://{rest}")
} else {
http.to_string()
/// Budget for the TUI's own calls once it is up. Tighter than the client
/// default — a spawn or kill that stalls should surface as a toast, not hang
/// the interface.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
async fn health_check(daemon: &DaemonClient) -> Result<(), TuiError> {
for _ in 0..3 {
if let Ok(info) = daemon.daemon_info().await {
if !maestro_protocol::is_compatible(&info.version) {
tracing::warn!(
daemon = %info.version,
client = %maestro_protocol::PROTOCOL_VERSION,
"daemon protocol version differs from this client's",
);
}
return Ok(());
}
tokio::time::sleep(HEALTH_TIMEOUT).await;
}
Err(TuiError::DaemonUnreachable(daemon.base().to_string()))
}
+124 -92
View File
@@ -4,7 +4,6 @@
//! All overlays are centered popups drawn over the rest of the UI; the
//! `Clear` widget wipes the underlying buffer before drawing.
use maestro_protocol::DaemonInfo;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
@@ -14,7 +13,6 @@ use ratatui::Frame;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SpawnField {
#[default]
Daemon,
Cwd,
Command,
Submit,
@@ -22,27 +20,23 @@ pub enum SpawnField {
#[derive(Debug, Default, Clone)]
pub struct SpawnModal {
pub daemons: Vec<DaemonInfo>,
pub daemon_idx: usize,
pub cwd: String,
pub command: String,
pub cwd: crate::text_input::TextInput,
pub command: crate::text_input::TextInput,
pub focused_field: SpawnField,
}
impl SpawnModal {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Daemon => SpawnField::Cwd,
SpawnField::Cwd => SpawnField::Command,
SpawnField::Command => SpawnField::Submit,
SpawnField::Submit => SpawnField::Daemon,
SpawnField::Submit => SpawnField::Cwd,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Daemon => SpawnField::Submit,
SpawnField::Cwd => SpawnField::Daemon,
SpawnField::Cwd => SpawnField::Submit,
SpawnField::Command => SpawnField::Cwd,
SpawnField::Submit => SpawnField::Command,
};
@@ -58,6 +52,10 @@ impl SpawnModal {
self.next_field();
SpawnInputResult::Consumed
}
InputKey::BackTab => {
self.prev_field();
SpawnInputResult::Consumed
}
InputKey::Esc => SpawnInputResult::Cancel,
InputKey::Enter => match self.focused_field {
SpawnField::Submit => SpawnInputResult::Submit,
@@ -66,52 +64,26 @@ impl SpawnModal {
SpawnInputResult::Consumed
}
},
InputKey::Char { c, .. } => {
// Everything else — characters, motion, editing, paste — goes to
// the focused field's text input.
other => {
match self.focused_field {
SpawnField::Cwd => self.cwd.push(*c),
SpawnField::Command => self.command.push(*c),
SpawnField::Daemon => {
if !self.daemons.is_empty() {
match c {
'j' => {
self.daemon_idx = (self.daemon_idx + 1) % self.daemons.len();
}
'k' => {
self.daemon_idx = (self.daemon_idx + self.daemons.len() - 1)
% self.daemons.len();
}
_ => {}
}
}
}
SpawnField::Cwd => self.cwd.handle_key(other),
SpawnField::Command => self.command.handle_key(other),
SpawnField::Submit => {}
}
SpawnInputResult::Consumed
}
InputKey::Backspace => {
match self.focused_field {
SpawnField::Cwd => {
self.cwd.pop();
}
SpawnField::Command => {
self.command.pop();
}
_ => {}
}
SpawnInputResult::Consumed
}
_ => SpawnInputResult::Consumed,
}
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 60, 12);
let area = centered(parent, 60, 10);
f.render_widget(Clear, area);
let block = Block::default().title(" spawn ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
@@ -124,53 +96,53 @@ impl SpawnModal {
Style::default()
}
};
let daemon_label = self
.daemons
.get(self.daemon_idx)
.map(|d| d.hostname.as_str())
.unwrap_or("(none)");
let field_row = |label: &str, value: &str, style: Style| {
Paragraph::new(Line::from(vec![
ratatui::text::Span::raw(format!("{label:8} ")),
ratatui::text::Span::styled(value.to_string(), style),
]))
// Each text field renders through the shared `TextInput`, which
// handles horizontal scrolling and reports where the terminal cursor
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
let label =
|text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} "))));
f.render_widget(label("cwd:"), rows[0]);
f.render_widget(label("command:"), rows[1]);
let draw_field = |f: &mut Frame,
input: &crate::text_input::TextInput,
row: Rect,
focused: bool|
-> u16 {
let style = if focused {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let area = Rect {
x: row.x.saturating_add(9),
y: row.y,
width: row.width.saturating_sub(9),
height: 1,
};
input.render(area, f.buffer_mut(), style)
};
f.render_widget(
field_row("daemon:", daemon_label, highlight(SpawnField::Daemon)),
rows[0],
);
f.render_widget(
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd);
let cmd_col = draw_field(
f,
&self.command,
rows[1],
self.focused_field == SpawnField::Command,
);
f.render_widget(
field_row("command:", &self.command, highlight(SpawnField::Command)),
rows[2],
);
f.render_widget(
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
"[ Spawn ]",
highlight(SpawnField::Submit),
)])),
rows[3],
rows[2],
);
// Place the cursor at the end of the currently-focused text field so
// the user can see where their typing lands. Label is fixed-width
// 8 chars + 1 space = column 9 inside the row.
let cursor_col_for = |value: &str| -> u16 {
inner
.x
.saturating_add(9)
.saturating_add(value.chars().count() as u16)
};
match self.focused_field {
SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[1].y)),
SpawnField::Command => {
f.set_cursor_position((cursor_col_for(&self.command), rows[2].y))
}
SpawnField::Daemon | SpawnField::Submit => {
// Non-text fields are indicated by the REVERSED style; no
SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
SpawnField::Submit => {
// The submit button is indicated by the REVERSED style; no
// cursor placement.
}
}
@@ -293,20 +265,12 @@ mod tests {
#[test]
fn spawn_modal_renders_field_labels() {
let modal = SpawnModal {
daemons: vec![DaemonInfo {
daemon_id: "k".into(),
hostname: "konishi".into(),
version: "0".into(),
daemon_url: "ws://x".into(),
home_dir: String::new(),
}],
daemon_idx: 0,
cwd: "/x".into(),
command: "bash".into(),
cwd: crate::text_input::TextInput::with_value("/x"),
command: crate::text_input::TextInput::with_value("bash"),
focused_field: SpawnField::Cwd,
};
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
for s in ["daemon", "cwd", "command", "Spawn", "konishi", "/x", "bash"] {
for s in ["cwd", "command", "Spawn", "/x", "bash"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
@@ -314,15 +278,13 @@ mod tests {
#[test]
fn spawn_modal_field_rotation() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Daemon);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Command);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Daemon);
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.prev_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
}
@@ -351,4 +313,74 @@ mod tests {
assert!(rendered.contains("EnterInsert"));
assert!(rendered.contains("OpenSpawnModal"));
}
/// Shift-Tab walked nowhere: `prev_field` existed with no caller, so the
/// spawn modal could only be cycled forwards.
#[test]
fn shift_tab_walks_the_spawn_modal_backwards() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Submit);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Command);
}
#[test]
fn tab_and_shift_tab_are_inverses() {
let mut modal = SpawnModal::default();
for _ in 0..3 {
modal.on_input(&crate::input::InputKey::Tab);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Cwd);
}
}
/// Editing keys must reach the focused field's text input, not a bare
/// String — this is what gives the operator cursor motion and word ops.
#[test]
fn editing_keys_reach_the_focused_text_input() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
for k in [
crate::input::InputKey::char('/'),
crate::input::InputKey::char('t'),
crate::input::InputKey::char('m'),
crate::input::InputKey::char('p'),
] {
modal.on_input(&k);
}
modal.on_input(&crate::input::InputKey::Left);
modal.on_input(&crate::input::InputKey::Backspace);
assert_eq!(modal.cwd.value(), "/tp");
// Tab to command, then a paste and ctrl-w land there, not in cwd.
modal.on_input(&crate::input::InputKey::Tab);
assert_eq!(modal.focused_field, SpawnField::Command);
modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into()));
assert_eq!(modal.command.value(), "/tmp/foo");
modal.on_input(&crate::input::InputKey::ctrl_char('w'));
assert_eq!(modal.command.value(), "");
assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused");
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (label is 8 chars + 1 space, modal is centered on screen).
#[test]
fn render_places_the_cursor_after_the_typed_text() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let modal = SpawnModal {
cwd: crate::text_input::TextInput::with_value("abc"),
..SpawnModal::default()
};
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| modal.render(f, f.area())).unwrap();
// Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9.
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6));
}
}
+1 -1
View File
@@ -137,7 +137,7 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
// always see the current mode + key hints.
render_status_bar(f, status_area, inputs.state);
match &inputs.state.overlay {
match inputs.state.overlay() {
Some(OverlayKind::Spawn) => {
inputs.spawn.render(f, area);
map.modal = Some(area);
+24 -51
View File
@@ -1,10 +1,10 @@
//! Sidebar widget — daemon→session tree, navigation-only.
//! Sidebar widget — session list, navigation-only.
//!
//! Status glyphs: ● attached / ○ live / ✗ dead.
use std::collections::HashSet;
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
use maestro_protocol::{Session, SessionStatus};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
@@ -15,7 +15,6 @@ use uuid::Uuid;
/// Renderable sidebar state.
#[derive(Debug, Clone)]
pub struct Sidebar {
pub daemons: Vec<DaemonInfo>,
pub sessions: Vec<Session>,
pub highlight: Option<Uuid>,
pub attached: HashSet<Uuid>,
@@ -43,28 +42,25 @@ impl Sidebar {
f.render_widget(block, area);
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
"daemons",
"sessions",
Style::default().add_modifier(Modifier::BOLD),
))];
for d in &self.daemons {
lines.push(Line::from(format!("● {}", d.hostname)));
for s in self.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
let label = name_for(s);
let style = if self.highlight == Some(s.id) {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{glyph} {label}"), style),
]));
}
for s in &self.sessions {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
let label = name_for(s);
let style = if self.highlight == Some(s.id) {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{glyph} {label}"), style),
]));
}
f.render_widget(Paragraph::new(lines), inner);
}
@@ -87,22 +83,11 @@ mod tests {
use ratatui::Terminal;
use std::collections::HashMap;
fn daemon(id: &str) -> DaemonInfo {
DaemonInfo {
daemon_id: id.into(),
hostname: id.into(),
version: "test".into(),
daemon_url: format!("ws://{id}:7070"),
home_dir: String::new(),
}
}
fn session_named(id: u128, daemon_id: &str, name: &str) -> Session {
fn session_named(id: u128, name: &str) -> Session {
let mut metadata = HashMap::new();
metadata.insert("name".into(), serde_json::Value::String(name.into()));
Session {
id: Uuid::from_u128(id),
daemon_id: daemon_id.into(),
command: "bash".into(),
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
@@ -132,21 +117,13 @@ mod tests {
}
#[test]
fn renders_hostname_and_session_names() {
fn renders_session_names() {
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![
session_named(1, "konishi", "claude"),
session_named(2, "konishi", "bash"),
],
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
highlight: None,
attached: Default::default(),
};
let rendered = render_to_string(&sb, 28, 12);
assert!(
rendered.contains("konishi"),
"missing daemon name:\n{rendered}"
);
assert!(
rendered.contains("claude"),
"missing session name:\n{rendered}"
@@ -161,8 +138,7 @@ mod tests {
fn attached_session_uses_filled_glyph() {
let sid = Uuid::from_u128(1);
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![session_named(1, "konishi", "claude")],
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
};
@@ -180,10 +156,9 @@ mod tests {
#[test]
fn dead_session_uses_cross_glyph() {
let mut sess = session_named(1, "konishi", "claude");
let mut sess = session_named(1, "claude");
sess.status = SessionStatus::Dead;
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![sess],
highlight: None,
attached: Default::default(),
@@ -200,7 +175,6 @@ mod tests {
fn falls_back_to_command_when_name_metadata_is_absent() {
let s = Session {
id: Uuid::from_u128(1),
daemon_id: "konishi".into(),
command: "vim".into(),
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
@@ -214,7 +188,6 @@ mod tests {
current_cwd: None,
};
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![s],
highlight: None,
attached: Default::default(),
+74 -4
View File
@@ -39,7 +39,11 @@ pub struct AppState {
pub mode: Mode,
pub sidebar_visible: bool,
pub sidebar_highlight: Option<Uuid>,
pub overlay: Option<OverlayKind>,
/// Private: every transition goes through [`AppState::show_overlay`],
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
/// from four call sites is what left a toast on screen with no way to
/// clear it.
overlay: Option<OverlayKind>,
}
impl Default for AppState {
@@ -64,6 +68,24 @@ impl AppState {
}
}
pub fn overlay(&self) -> Option<&OverlayKind> {
self.overlay.as_ref()
}
pub fn show_overlay(&mut self, kind: OverlayKind) {
self.overlay = Some(kind);
}
/// Clear whatever overlay is up. Returns true if one was showing.
pub fn dismiss_overlay(&mut self) -> bool {
self.overlay.take().is_some()
}
/// Surface a transient message. Replaces any overlay currently up.
pub fn toast(&mut self, message: impl Into<String>) {
self.overlay = Some(OverlayKind::Toast(message.into()));
}
pub fn active_tab(&self) -> &Tab {
&self.tabs[self.active_tab_idx]
}
@@ -83,14 +105,21 @@ impl AppState {
/// Drop the active tab. No-op if it's the only one — there must always
/// be at least one tab.
pub fn close_tab(&mut self) {
///
/// Returns the session that tab was showing, if any, so the caller can
/// release its emulator. Without that the tab's WebSocket task and vt100
/// parser outlive the tab forever.
pub fn close_tab(&mut self) -> Option<Uuid> {
if self.tabs.len() <= 1 {
return;
return None;
}
self.tabs.remove(self.active_tab_idx);
let closed = self.tabs.remove(self.active_tab_idx);
if self.active_tab_idx >= self.tabs.len() {
self.active_tab_idx = self.tabs.len() - 1;
}
// Only release it if no other tab still shows the same session.
let still_shown = self.tabs.iter().any(|t| t.pane == closed.pane);
closed.pane.filter(|_| !still_shown)
}
pub fn next_tab(&mut self) {
@@ -277,4 +306,45 @@ mod tests {
// Doesn't force focus back; sidebar shown but focus stays where it was.
assert_eq!(state.focus, Focus::Pane);
}
/// The emulator leak: a closed tab's session must come back so the caller
/// can abort its WebSocket task.
#[test]
fn close_tab_reports_the_session_it_dropped() {
let mut state = AppState::new();
let sid = Uuid::from_u128(3);
state.new_tab();
state.active_tab_mut().pane = Some(sid);
assert_eq!(state.close_tab(), Some(sid));
}
/// ...but not while another tab still shows it.
#[test]
fn close_tab_keeps_a_session_another_tab_still_shows() {
let mut state = AppState::new();
let sid = Uuid::from_u128(4);
state.active_tab_mut().pane = Some(sid);
state.new_tab();
state.active_tab_mut().pane = Some(sid);
assert_eq!(state.close_tab(), None);
}
#[test]
fn close_tab_is_a_no_op_on_the_last_tab() {
let mut state = AppState::new();
state.active_tab_mut().pane = Some(Uuid::from_u128(5));
assert_eq!(state.close_tab(), None);
assert_eq!(state.tabs.len(), 1);
}
#[test]
fn dismiss_overlay_reports_whether_one_was_showing() {
let mut state = AppState::new();
assert!(!state.dismiss_overlay());
state.toast("boom");
assert!(state.dismiss_overlay());
assert!(state.overlay().is_none());
}
}
+15 -3
View File
@@ -5,7 +5,9 @@
use std::io;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
@@ -26,7 +28,12 @@ impl TerminalGuard {
pub fn enter() -> Result<Self, TerminalError> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
execute!(
out,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste
)?;
Ok(TerminalGuard)
}
@@ -34,7 +41,12 @@ impl TerminalGuard {
/// call it without producing weird errors.
fn restore() {
let mut out = io::stdout();
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
let _ = execute!(
out,
DisableBracketedPaste,
DisableMouseCapture,
LeaveAlternateScreen
);
let _ = disable_raw_mode();
}
}
+462
View File
@@ -0,0 +1,462 @@
//! Single-line text-input primitive shared by every TUI form field.
//!
//! Owns a `String`, a character-indexed cursor, and horizontal scroll state.
//! All editing goes through [`TextInput::handle_key`] so callers feed the same
//! canonical [`InputKey`] stream the rest of the app uses; bracketed paste
//! arrives as one [`InputKey::Paste`] and is inserted as a single operation.
//!
//! This is the one piece of the TUI form layer that unit-tests without a
//! terminal: drive it with `InputKey` sequences and inspect value/cursor.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use crate::input::InputKey;
#[derive(Debug, Default, Clone)]
pub struct TextInput {
value: String,
/// Cursor position as a char offset into `value`.
cursor: usize,
/// Char offset of the leftmost visible cell. A `Cell` so `render` can
/// re-clamp it without `&mut self` — widgets are drawn through shared
/// references in this codebase.
scroll: std::cell::Cell<usize>,
}
impl TextInput {
pub fn with_value(value: impl Into<String>) -> Self {
let mut input = Self {
value: value.into(),
..Self::default()
};
input.cursor = input.value.chars().count();
input
}
pub fn value(&self) -> &str {
&self.value
}
pub fn cursor(&self) -> usize {
self.cursor
}
/// Char offset of the leftmost visible cell; only meaningful after
/// [`TextInput::render`] has clamped it to the field width.
pub fn scroll_offset(&self) -> usize {
self.scroll.get()
}
/// Insert `text` at the cursor as one operation. Used both for typed
/// characters and for whole bracketed-paste payloads.
pub fn insert(&mut self, text: &str) {
let at = self.byte_offset(self.cursor);
self.value.insert_str(at, text);
self.cursor += text.chars().count();
}
pub fn handle_key(&mut self, key: &InputKey) {
match key {
InputKey::Char {
c,
ctrl: false,
alt: false,
..
} => self.insert(&c.to_string()),
InputKey::Char {
c: 'w', ctrl: true, ..
} => self.delete_word_backwards(),
InputKey::Char {
c: 'k', ctrl: true, ..
} => self.kill_to_end(),
// Emacs-style word motion; ctrl-arrow reaches us as a plain
// Left/Right today, so these are the dependable binding.
InputKey::Char {
c: 'b', ctrl: true, ..
} => self.cursor = self.word_left(),
InputKey::Char {
c: 'f', ctrl: true, ..
} => self.cursor = self.word_right(),
InputKey::Left => {
self.cursor = self.cursor.saturating_sub(1);
}
InputKey::Right => {
if self.cursor < self.value.chars().count() {
self.cursor += 1;
}
}
InputKey::Home => self.cursor = 0,
InputKey::End => self.cursor = self.value.chars().count(),
InputKey::Backspace => {
if self.cursor > 0 {
let at = self.byte_offset(self.cursor - 1);
let end = self.byte_offset(self.cursor);
self.value.replace_range(at..end, "");
self.cursor -= 1;
}
}
InputKey::Delete => {
if self.cursor < self.value.chars().count() {
let at = self.byte_offset(self.cursor);
let end = self.byte_offset(self.cursor + 1);
self.value.replace_range(at..end, "");
}
}
InputKey::Paste(text) => self.insert(text),
_ => {}
}
}
/// Draw into `area` (expected one row tall), scrolling horizontally so the
/// cursor stays visible, and return the screen column where the caller
/// should place the terminal cursor.
pub fn render(&self, area: Rect, buf: &mut Buffer, style: Style) -> u16 {
let width = area.width as usize;
let len = self.value.chars().count();
let scroll = self.scroll.get().min(len);
let scroll = if self.cursor < scroll {
self.cursor
} else if width > 0 && self.cursor.saturating_sub(scroll) >= width {
self.cursor + 1 - width
} else {
scroll
};
self.scroll.set(scroll);
for (col, c) in self.value.chars().skip(scroll).take(width).enumerate() {
buf[(area.x + col as u16, area.y)]
.set_symbol(&c.to_string())
.set_style(style);
}
area.x + self.cursor.saturating_sub(scroll) as u16
}
fn byte_offset(&self, char_index: usize) -> usize {
self.value
.char_indices()
.nth(char_index)
.map(|(b, _)| b)
.unwrap_or(self.value.len())
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Start of the word before the cursor, skipping any separators first —
/// emacs `backward-word`.
fn word_left(&self) -> usize {
let chars: Vec<char> = self.value.chars().collect();
let mut i = self.cursor;
while i > 0 && !Self::is_word_char(chars[i - 1]) {
i -= 1;
}
while i > 0 && Self::is_word_char(chars[i - 1]) {
i -= 1;
}
i
}
/// Start of the word after the cursor, skipping the rest of the current
/// word first — emacs `forward-word`.
fn word_right(&self) -> usize {
let chars: Vec<char> = self.value.chars().collect();
let len = chars.len();
let mut i = self.cursor;
while i < len && !Self::is_word_char(chars[i]) {
i += 1;
}
while i < len && Self::is_word_char(chars[i]) {
i += 1;
}
i
}
fn kill_to_end(&mut self) {
let at = self.byte_offset(self.cursor);
self.value.truncate(at);
}
/// Shell-style ctrl-w: kill back to the previous whitespace, taking any
/// trailing whitespace run with it — so `cd /tmp/foo` loses the whole
/// path segment in one keystroke.
fn delete_word_backwards(&mut self) {
let chars: Vec<char> = self.value.chars().collect();
let mut i = self.cursor;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
let at = self.byte_offset(i);
let end = self.byte_offset(self.cursor);
self.value.replace_range(at..end, "");
self.cursor = i;
}
}
#[cfg(test)]
mod tests {
use super::*;
use InputKey::{Backspace, Delete, End, Home, Left, Right};
fn typed(s: &str) -> Vec<InputKey> {
s.chars().map(InputKey::char).collect()
}
fn fed(keys: &[InputKey]) -> TextInput {
let mut input = TextInput::default();
for k in keys {
input.handle_key(k);
}
input
}
fn ctrl(c: char) -> InputKey {
InputKey::ctrl_char(c)
}
#[test]
fn typing_lands_in_order() {
let input = fed(&typed("hello"));
assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 5);
}
#[test]
fn backspace_removes_the_last_typed_char() {
let mut input = fed(&typed("hello"));
input.handle_key(&Backspace);
assert_eq!(input.value(), "hell");
assert_eq!(input.cursor(), 4);
}
#[test]
fn left_then_insert_fixes_a_middle_typo() {
let mut input = fed(&typed("helo"));
input.handle_key(&Left);
input.handle_key(&Left);
input.handle_key(&InputKey::char('l'));
assert_eq!(input.value(), "hello");
assert_eq!(input.cursor(), 3);
}
#[test]
fn motion_keys_stop_at_the_edges() {
let mut input = fed(&typed("abc"));
for _ in 0..5 {
input.handle_key(&Left);
}
assert_eq!(input.cursor(), 0);
input.handle_key(&Left);
assert_eq!(input.cursor(), 0);
for _ in 0..5 {
input.handle_key(&Right);
}
assert_eq!(input.cursor(), 3);
}
#[test]
fn home_and_end_jump_to_the_ends() {
let mut input = fed(&typed("abcdef"));
input.handle_key(&Home);
assert_eq!(input.cursor(), 0);
input.handle_key(&InputKey::char('x'));
assert_eq!(input.value(), "xabcdef");
input.handle_key(&End);
input.handle_key(&InputKey::char('!'));
assert_eq!(input.value(), "xabcdef!");
}
/// Cursor starts after the final `/`; word-left lands on word starts,
/// never inside a separator run.
#[test]
fn ctrl_b_walks_left_one_word_at_a_time() {
let mut input = TextInput::with_value("/tmp/foo bar-baz/");
assert_eq!(input.cursor(), 17);
for expected in [13, 9, 5, 1, 0, 0] {
input.handle_key(&ctrl('b'));
assert_eq!(input.cursor(), expected);
}
}
#[test]
fn ctrl_f_walks_right_one_word_at_a_time() {
let mut input = TextInput::with_value("/tmp/foo bar");
input.handle_key(&Home);
for expected in [4, 8, 12, 12] {
input.handle_key(&ctrl('f'));
assert_eq!(input.cursor(), expected);
}
}
#[test]
fn backspace_deletes_before_the_cursor() {
let mut input = fed(&typed("abcdef"));
input.handle_key(&Left);
input.handle_key(&Left);
input.handle_key(&Left);
input.handle_key(&Backspace);
assert_eq!(input.value(), "abdef");
assert_eq!(input.cursor(), 2);
}
#[test]
fn backspace_on_empty_input_is_a_noop() {
let mut input = TextInput::default();
input.handle_key(&Backspace);
assert_eq!(input.value(), "");
assert_eq!(input.cursor(), 0);
}
#[test]
fn delete_removes_the_char_under_the_cursor() {
let mut input = fed(&typed("abcdef"));
input.handle_key(&Home);
input.handle_key(&Delete);
assert_eq!(input.value(), "bcdef");
assert_eq!(input.cursor(), 0);
}
#[test]
fn delete_past_the_end_is_a_noop() {
let mut input = TextInput::with_value("abc");
input.handle_key(&End);
input.handle_key(&Delete);
assert_eq!(input.value(), "abc");
}
/// `cd /tmp/foo` with ctrl-w leaves `cd ` — the whole path segment is one
/// word to a shell (readline kills back to whitespace, spaces included).
#[test]
fn ctrl_w_deletes_the_word_behind_the_cursor() {
let mut input = fed(&[typed("cd ").as_slice(), typed("/tmp/foo").as_slice()].concat());
input.handle_key(&ctrl('w'));
assert_eq!(input.value(), "cd ");
assert_eq!(input.cursor(), 3);
}
#[test]
fn ctrl_w_stops_before_the_separator_run() {
let mut input = TextInput::with_value("a b");
input.cursor = 5;
input.handle_key(&ctrl('w'));
assert_eq!(input.value(), "a ");
assert_eq!(input.cursor(), 4);
}
#[test]
fn ctrl_k_kills_everything_after_the_cursor() {
let mut input = fed(&typed("spawn --debug extra"));
for _ in 0..14 {
input.handle_key(&Left);
}
input.handle_key(&ctrl('k'));
assert_eq!(input.value(), "spawn");
assert_eq!(input.cursor(), 5);
}
/// A paste arrives as one event carrying the whole payload and must land
/// at the cursor as a single insertion, not per-character.
#[test]
fn paste_inserts_whole_payload_at_the_cursor() {
let mut input = fed(&typed("cd "));
input.handle_key(&InputKey::Paste("/srv/work".into()));
assert_eq!(input.value(), "cd /srv/work");
assert_eq!(input.cursor(), 12);
// What follows still behaves like ordinary typed text.
input.handle_key(&InputKey::char('/'));
assert_eq!(input.value(), "cd /srv/work/");
}
#[test]
fn paste_between_existing_text_keeps_both_halves() {
let mut input = TextInput::with_value("ac");
input.handle_key(&Left);
input.handle_key(&InputKey::Paste("b×".into()));
assert_eq!(input.value(), "ab×c");
assert_eq!(input.cursor(), 3, "cursor counts chars, not bytes");
}
#[test]
fn rendering_an_empty_field_points_at_its_first_column() {
let input = TextInput::default();
let area = Rect::new(10, 5, 20, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(col, 10);
}
#[test]
fn rendering_reports_the_visible_cursor_column() {
let mut input = TextInput::with_value("hello");
input.handle_key(&Left);
input.handle_key(&Left);
let area = Rect::new(10, 5, 20, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(col, 13);
for (i, expected) in ["h", "e", "l", "l", "o"].iter().enumerate() {
assert_eq!(buf[(10 + i as u16, 5)].symbol(), *expected);
}
}
#[test]
fn rendering_a_value_wider_than_the_field_shows_its_tail() {
let value = "0123456789abcdefghij";
let input = TextInput::with_value(value);
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(col, 9, "cursor sits on the last visible column");
for (i, expected) in value.chars().skip(input.scroll_offset()).enumerate() {
assert_eq!(
buf[(i as u16, 0)].symbol(),
expected.to_string(),
"cell {i}"
);
}
}
/// Moving left into the hidden region must scroll the window back so the
/// cursor never disappears off the left edge.
#[test]
fn moving_left_into_the_hidden_region_scrolls_back() {
let value = "0123456789abcdefghij";
let mut input = TextInput::with_value(value);
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
input.render(area, &mut buf, Style::default());
assert_eq!(input.cursor(), 20);
input.handle_key(&Home);
for _ in 0..5 {
input.handle_key(&Right);
}
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(input.cursor(), 5);
assert_eq!(
input.value().chars().nth(input.scroll_offset()),
Some('5'),
"window scrolled back to the cursor"
);
assert_eq!(col, 0);
assert_eq!(buf[(0, 0)].symbol(), "5");
}
#[test]
fn shrinking_the_value_clamps_scroll_and_cursor() {
let mut input = TextInput::with_value("0123456789abcdefghij");
input.handle_key(&Home);
input.handle_key(&ctrl('k'));
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let col = input.render(area, &mut buf, Style::default());
assert_eq!(input.value(), "");
assert_eq!(col, 0);
}
}
+23 -81
View File
@@ -1,100 +1,42 @@
//! WS round-trip test for `CatalogStore::run_events_loop`. Spins a tiny axum
//! server that emulates the daemon's `/api/events` contract: send a `Snapshot`
//! on connect, then a delta.
//! `CatalogStore::run_events_loop` against a daemon serving the real
//! `/api/events` contract, rather than a scripted socket.
use std::time::Duration;
use axum::extract::ws::{Message, WebSocketUpgrade};
use axum::response::Response;
use axum::routing::any;
use axum::Router;
use chrono::Utc;
use maestro_protocol::{DaemonInfo, RegistryEvent, Session, SessionStatus};
use maestro_testing::{session, MockDaemon};
use maestro_tui::catalog::CatalogStore;
use uuid::Uuid;
fn daemon(id: &str) -> DaemonInfo {
DaemonInfo {
daemon_id: id.into(),
hostname: format!("{id}.lan"),
version: "test".into(),
daemon_url: format!("ws://{id}.lan:7070"),
home_dir: String::new(),
}
}
fn session(id: u128, daemon_id: &str) -> Session {
Session {
id: Uuid::from_u128(id),
daemon_id: daemon_id.into(),
command: "bash".into(),
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
metadata: Default::default(),
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
}
}
async fn events_handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(|mut socket| async move {
let snapshot = RegistryEvent::Snapshot {
daemons: vec![daemon("konishi")],
sessions: vec![],
};
let text = serde_json::to_string(&snapshot).unwrap();
if socket.send(Message::Text(text)).await.is_err() {
return;
}
// Small delay so the client has the snapshot applied before the delta.
tokio::time::sleep(Duration::from_millis(50)).await;
let added = RegistryEvent::SessionAdded {
session: session(1, "konishi"),
};
let text = serde_json::to_string(&added).unwrap();
let _ = socket.send(Message::Text(text)).await;
// Keep the connection alive so the client doesn't enter reconnect.
tokio::time::sleep(Duration::from_secs(2)).await;
})
}
#[tokio::test(flavor = "multi_thread")]
async fn run_events_loop_applies_snapshot_then_delta() {
let app = Router::new().route("/api/events", any(events_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
async fn applies_the_snapshot_then_live_deltas() {
let mock = MockDaemon::start().await;
mock.add_session(session(Uuid::from_u128(1), "/home/mock", "bash"));
let ws_url = mock.client().events_url().expect("events url");
let store = CatalogStore::default();
let bg = store.clone();
let ws_url = format!("ws://{addr}/api/events");
tokio::spawn(async move { bg.run_events_loop(ws_url).await });
// Bootstrap should land within ~250 ms.
tokio::time::timeout(
Duration::from_millis(500),
store.wait_for_initial_snapshot(),
)
.await
.expect("initial snapshot never arrived");
tokio::time::timeout(Duration::from_secs(3), store.wait_for_initial_snapshot())
.await
.expect("initial snapshot");
assert_eq!(store.snapshot().sessions.len(), 1);
let snap = store.snapshot();
assert_eq!(snap.daemons.len(), 1);
assert_eq!(snap.daemons[0].daemon_id, "konishi");
mock.add_session(session(Uuid::from_u128(2), "/home/mock", "claude"));
wait_until(|| store.snapshot().sessions.len() == 2).await;
// Delta arrives shortly after. Poll for up to 500 ms.
for _ in 0..50 {
if store.snapshot().sessions.len() == 1 {
mock.remove_session(Uuid::from_u128(1));
wait_until(|| store.snapshot().sessions.len() == 1).await;
assert_eq!(store.snapshot().sessions[0].id, Uuid::from_u128(2));
}
async fn wait_until(mut cond: impl FnMut() -> bool) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
if cond() {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("delta never propagated; got {:?}", store.snapshot());
panic!("timed out waiting for the catalog to converge");
}
+4
View File
@@ -22,8 +22,10 @@ dirs.workspace = true
futures-util.workspace = true
include_dir.workspace = true
maestro-config.workspace = true
maestro-protocol.workspace = true
mime_guess.workspace = true
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
@@ -31,6 +33,8 @@ tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
maestro-testing.workspace = true
uuid.workspace = true
reqwest.workspace = true
serde_json.workspace = true
tempfile.workspace = true
+1 -2
View File
@@ -31,7 +31,6 @@ The binary reads `~/.config/maestro/config.toml`. The `[web]` section:
[web]
bind_addr = "127.0.0.1:8080" # default
daemon_url = "http://127.0.0.1:7070" # default
# dev_proxy_target = "http://127.0.0.1:5173" # only when developing the frontend
```
Env-var overrides (all optional):
@@ -79,5 +78,5 @@ pnpm dev
```
Vite serves on `:5173` and proxies `/api/*` straight to `maestrod` at
`127.0.0.1:7070` (override with `MAESTRO_REGISTRY=...`). You don't need to
`127.0.0.1:7070` (override with `MAESTRO_DAEMON_URL=...`). You don't need to
rebuild the Rust binary while iterating on the frontend.
+51 -26
View File
@@ -1,16 +1,53 @@
//! Build the embedded SPA bundle before the crate compiles.
//! Resolve the SPA bundle that gets embedded at compile time.
//!
//! `static_bundle.rs` embeds `web/dist` at compile time via `include_dir!`.
//! This script guarantees that directory holds a freshly built bundle by
//! running `pnpm install --frozen-lockfile && pnpm build` in `web/`. pnpm and
//! node are HARD requirements — a missing toolchain fails the build with an
//! actionable message rather than silently shipping the placeholder.
//! `static_bundle.rs` embeds a directory via `include_dir!`, and the directory
//! it reads is whatever this script puts in `MAESTRO_WEB_BUNDLE_DIR`.
//!
//! Two modes:
//!
//! * `MAESTRO_WEB_DIST` set — the bundle was built elsewhere (Nix, CI, a
//! release pipeline) and that directory is embedded as-is. No node, no pnpm.
//! * unset — the default developer path: run `pnpm install --frozen-lockfile
//! && pnpm build` in `web/` and embed `web/dist`. pnpm and node are HARD
//! requirements here; a missing toolchain fails with an actionable message
//! rather than silently shipping the placeholder.
use std::env;
use std::path::PathBuf;
use std::process::Command;
fn main() {
println!("cargo:rerun-if-env-changed=MAESTRO_WEB_DIST");
let dist_dir = match env::var_os("MAESTRO_WEB_DIST") {
Some(prebuilt) => PathBuf::from(prebuilt),
None => build_with_pnpm(),
};
// Sanity-check that the directory really holds a bundle. A green build
// that embeds an empty/placeholder dir is worse than a loud failure here.
let index_html = dist_dir.join("index.html");
let produced = std::fs::metadata(&index_html)
.map(|m| m.is_file() && m.len() > 0)
.unwrap_or(false);
if !produced {
panic!(
"no SPA bundle at {} (missing or empty index.html) — \
check the vite `outDir`, or the MAESTRO_WEB_DIST override.",
index_html.display()
);
}
// `include_dir!` expands `$VAR` in its path argument against the env rustc
// is invoked with, which is what `cargo:rustc-env` sets.
println!(
"cargo:rustc-env=MAESTRO_WEB_BUNDLE_DIR={}",
dist_dir.display()
);
}
/// The default path: build `web/` in-tree with pnpm and return `web/dist`.
fn build_with_pnpm() -> PathBuf {
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"));
let web_dir = manifest_dir.join("../../web");
@@ -29,11 +66,11 @@ fn main() {
// Also watch the embedded OUTPUT directory. The input watches above decide
// whether THIS script re-runs and regenerates `web/dist`; but `include_dir!`
// in `static_bundle.rs` reads `web/dist`, and cargo has no other declared
// dependency on those output files. Without this watch, an incremental build
// could regenerate `web/dist` yet keep the OLD bundle embedded. Tying the
// crate's recompilation to the regenerated output makes the fresh assets
// actually get re-embedded.
// in `static_bundle.rs` reads that directory, and cargo has no other
// declared dependency on those output files. Without this watch, an
// incremental build could regenerate `web/dist` yet keep the OLD bundle
// embedded. Tying the crate's recompilation to the regenerated output makes
// the fresh assets actually get re-embedded.
let dist_dir = web_dir.join("dist");
println!("cargo:rerun-if-changed={}", dist_dir.display());
@@ -51,20 +88,7 @@ fn main() {
"pnpm build",
);
// Sanity-check that pnpm build actually produced the bundle `include_dir!`
// expects. A green build that embeds an empty/placeholder dir is worse than
// a loud failure here.
let index_html = dist_dir.join("index.html");
let produced = std::fs::metadata(&index_html)
.map(|m| m.is_file() && m.len() > 0)
.unwrap_or(false);
if !produced {
panic!(
"`pnpm build` did not produce the expected bundle at {} \
(missing or empty) — check the vite `outDir`.",
index_html.display()
);
}
dist_dir
}
fn require_tool(tool: &str) {
@@ -76,7 +100,8 @@ fn require_tool(tool: &str) {
if !ok {
panic!(
"maestro-web requires `{tool}` to build its embedded SPA bundle. \
Install pnpm + node (https://pnpm.io) or use a prebuilt binary."
Install pnpm + node (https://pnpm.io), or point MAESTRO_WEB_DIST \
at a prebuilt bundle."
);
}
}
+4 -26
View File
@@ -5,7 +5,9 @@ use std::path::{Path, PathBuf};
use maestro_config::MaestroConfigFile;
pub use maestro_config::{default_config_path, resolve_config_path, WebSection, CONFIG_PATH_ENV};
pub use maestro_config::default_config_path;
use maestro_config::WebSection;
/// Default bind address when neither env nor file specify one.
pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080";
@@ -14,7 +16,6 @@ pub const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:7070";
pub const ENV_WEB_BIND_ADDR: &str = "MAESTRO_WEB_BIND_ADDR";
pub const ENV_WEB_DAEMON_URL: &str = "MAESTRO_WEB_DAEMON_URL";
pub const ENV_WEB_DEV_PROXY_TARGET: &str = "MAESTRO_WEB_DEV_PROXY_TARGET";
fn default_bind_addr() -> SocketAddr {
DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr")
@@ -33,17 +34,12 @@ pub struct WebConfig {
/// Daemon HTTP base URL — e.g. `http://127.0.0.1:7070`. Always a base
/// without trailing slash and without a path.
pub daemon_url: String,
/// Optional dev override: when set, `/` (and only `/`) is proxied here
/// instead of being served from the embedded bundle. Set to the Vite dev
/// server URL during frontend development.
pub dev_proxy_target: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct EnvOverrides {
pub bind_addr: Option<String>,
pub daemon_url: Option<String>,
pub dev_proxy_target: Option<String>,
}
impl EnvOverrides {
@@ -51,7 +47,6 @@ impl EnvOverrides {
Self {
bind_addr: std::env::var(ENV_WEB_BIND_ADDR).ok(),
daemon_url: std::env::var(ENV_WEB_DAEMON_URL).ok(),
dev_proxy_target: std::env::var(ENV_WEB_DEV_PROXY_TARGET).ok(),
}
}
}
@@ -59,11 +54,7 @@ impl EnvOverrides {
/// Resolve the runtime configuration. Missing-file is fine: every field has
/// a default and the binary is happy to run with no config.toml at all.
pub fn load_config(path: &Path, env: EnvOverrides) -> Result<WebConfig, WebConfigError> {
let file = if path.exists() {
MaestroConfigFile::load(path)?
} else {
MaestroConfigFile::default()
};
let file = MaestroConfigFile::load(path)?;
resolve_with(file.web, env)
}
@@ -80,15 +71,9 @@ fn resolve_with(web: WebSection, env: EnvOverrides) -> Result<WebConfig, WebConf
.trim_end_matches('/')
.to_string();
let dev_proxy_target = env
.dev_proxy_target
.or(web.dev_proxy_target)
.map(|s| s.trim_end_matches('/').to_string());
Ok(WebConfig {
bind_addr,
daemon_url,
dev_proxy_target,
})
}
@@ -109,7 +94,6 @@ mod tests {
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.bind_addr, default_bind_addr());
assert_eq!(cfg.daemon_url, "http://127.0.0.1:7070");
assert!(cfg.dev_proxy_target.is_none());
}
#[test]
@@ -122,17 +106,12 @@ mod tests {
[web]
bind_addr = "127.0.0.1:9090"
daemon_url = "http://127.0.0.1:9070"
dev_proxy_target = "http://127.0.0.1:5173"
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.bind_addr, "127.0.0.1:9090".parse().unwrap());
assert_eq!(cfg.daemon_url, "http://127.0.0.1:9070");
assert_eq!(
cfg.dev_proxy_target.as_deref(),
Some("http://127.0.0.1:5173")
);
}
#[test]
@@ -151,7 +130,6 @@ daemon_url = "http://127.0.0.1:9070"
let env = EnvOverrides {
bind_addr: Some("0.0.0.0:8080".into()),
daemon_url: Some("http://daemon:7070".into()),
dev_proxy_target: None,
};
let cfg = load_config(&path, env).unwrap();
assert_eq!(cfg.bind_addr, "0.0.0.0:8080".parse().unwrap());
-3
View File
@@ -1,8 +1,5 @@
//! `maestro-web` — standalone HTTP server that hosts the Maestro browser UI
//! and reverse-proxies API calls to a co-located `maestrod`.
//!
//! See `docs/superpowers/specs/2026-05-26-connection-orchestration.md` §UI
//! hosting for the architectural decisions.
#![deny(missing_debug_implementations)]
+1 -9
View File
@@ -47,17 +47,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
tracing::info!(
bind = %cfg.bind_addr,
daemon = %cfg.daemon_url,
dev_proxy = ?cfg.dev_proxy_target,
"loaded config",
);
if cfg.dev_proxy_target.is_some() {
tracing::warn!(
"dev_proxy_target is set in config but not yet honored by the server \
(NAP-241 v1 ships only the embedded-bundle path)"
);
}
let router = router(&cfg);
let router = router(&cfg)?;
let listener = TcpListener::bind(cfg.bind_addr).await?;
tracing::info!(addr = %listener.local_addr()?, "serving");
axum::serve(listener, router)
+117 -37
View File
@@ -10,9 +10,11 @@ use std::sync::Arc;
use axum::body::Body;
use axum::extract::ws::{Message as AxMessage, WebSocket};
use axum::extract::{Request, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode, Uri};
use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::routes::{self, UrlError};
use maestro_protocol::ApiError;
use tokio_tungstenite::tungstenite::Message as WsMessage;
#[derive(Debug, Clone)]
@@ -25,21 +27,13 @@ pub struct UpstreamConfig {
}
impl UpstreamConfig {
pub fn from_daemon_url(daemon_url: &str) -> Self {
let http_base = daemon_url.trim_end_matches('/').to_string();
let ws_base = if let Some(rest) = http_base.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = http_base.strip_prefix("http://") {
format!("ws://{rest}")
} else {
// Caller passed something already scheme-less or pre-wrapped; trust
// it. Validation happens at config-load time.
http_base.clone()
};
Self {
pub fn from_daemon_url(daemon_url: &str) -> Result<Self, UrlError> {
let http_base = routes::normalize_base(daemon_url).to_string();
let ws_base = routes::ws_base(&http_base)?;
Ok(Self {
daemon_http_base: Arc::new(http_base),
daemon_ws_base: Arc::new(ws_base),
}
})
}
}
@@ -170,29 +164,22 @@ fn filter_response_headers(headers: &reqwest::header::HeaderMap) -> HeaderMap {
fn bad_gateway(msg: &str) -> Response {
tracing::warn!(error = msg, "proxy: bad gateway");
// Plain JSON without a serde_json dep — the message is short and we control
// every byte we write, so manual escaping of `"` + `\` is enough here.
let escaped = msg.replace('\\', "\\\\").replace('"', "\\\"");
let body = format!(r#"{{"error":{{"code":"proxy_failed","message":"{escaped}"}}}}"#);
(
StatusCode::BAD_GATEWAY,
[(header::CONTENT_TYPE, "application/json")],
body,
)
.into_response()
api_error(StatusCode::BAD_GATEWAY, "proxy_failed", msg)
}
fn payload_too_large() -> Response {
tracing::warn!(limit = MAX_REQUEST_BODY, "proxy: request body exceeds cap");
let body = format!(
r#"{{"error":{{"code":"payload_too_large","message":"request body exceeds {MAX_REQUEST_BODY} bytes"}}}}"#
);
(
api_error(
StatusCode::PAYLOAD_TOO_LARGE,
[(header::CONTENT_TYPE, "application/json")],
body,
"payload_too_large",
&format!("request body exceeds {MAX_REQUEST_BODY} bytes"),
)
.into_response()
}
/// Answer in the same envelope the daemon uses, so a client cannot tell whether
/// a failure came from the daemon or from this proxy.
fn api_error(status: StatusCode, code: &str, message: &str) -> Response {
(status, axum::Json(ApiError::new(code, message))).into_response()
}
// ---------------------------------------------------------------------------
@@ -202,12 +189,24 @@ fn payload_too_large() -> Response {
/// Open an upstream WS connection at `daemon_ws_base + path?query` and pump
/// frames in both directions until either side closes. Used for
/// `/api/attach/:id` and `/api/events`.
pub async fn pump_ws(client: WebSocket, upstream_url: String) {
pub async fn pump_ws(mut client: WebSocket, upstream_url: String) {
let (upstream, _) = match tokio_tungstenite::connect_async(&upstream_url).await {
Ok(pair) => pair,
Err(e) => {
tracing::warn!(url = %upstream_url, error = %e, "proxy: upstream WS connect failed");
// Drop the client socket — there's nothing useful to send it.
// The daemon rejects an attach it can't serve (unknown session)
// with a structured HTTP error *before* the upgrade. We've already
// upgraded the browser socket, so a bare drop reaches it as an
// opaque 1006 close — the attach pane just flashes and vanishes with
// no reason. Relay the daemon's reason as an application close frame
// the UI can show (#39).
let (code, reason) = upstream_close_reason(&e);
let _ = client
.send(AxMessage::Close(Some(axum::extract::ws::CloseFrame {
code,
reason: reason.into(),
})))
.await;
return;
}
};
@@ -274,6 +273,42 @@ pub async fn pump_ws(client: WebSocket, upstream_url: String) {
}
}
/// Largest application close code we emit; the browser surfaces it as
/// `CloseEvent.code`. Codes in 4000-4999 are reserved for application use.
const CLOSE_UPSTREAM_REJECTED: u16 = 4502;
/// Turn a failed upstream WS handshake into a browser-facing close code +
/// reason. When the daemon rejected the upgrade with an [`ApiError`] body,
/// surface that message; otherwise fall back to a generic gateway reason. The
/// reason rides a WS control frame, so it's truncated to the 123-byte limit.
fn upstream_close_reason(e: &tokio_tungstenite::tungstenite::Error) -> (u16, String) {
use tokio_tungstenite::tungstenite::Error as WsError;
let msg = match e {
WsError::Http(resp) => resp
.body()
.as_ref()
.and_then(|b| serde_json::from_slice::<ApiError>(b).ok())
.map(|e| e.error.message)
.unwrap_or_else(|| "the session's daemon rejected the attach".to_owned()),
_ => "could not reach the session's daemon".to_owned(),
};
(CLOSE_UPSTREAM_REJECTED, truncate_reason(&msg))
}
/// Clamp a close reason to the WS control-frame budget (125 bytes total, 2 for
/// the code → 123 for the reason), respecting UTF-8 char boundaries.
fn truncate_reason(s: &str) -> String {
const MAX: usize = 123;
if s.len() <= MAX {
return s.to_owned();
}
let mut end = MAX;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_owned()
}
/// Compose the upstream WS URL from the base + path + query of the inbound
/// request.
pub fn upstream_ws_url(state: &ProxyState, uri: &Uri) -> String {
@@ -292,14 +327,14 @@ mod tests {
#[test]
fn ws_base_swaps_http_scheme() {
let u = UpstreamConfig::from_daemon_url("http://127.0.0.1:7070/");
let u = UpstreamConfig::from_daemon_url("http://127.0.0.1:7070/").unwrap();
assert_eq!(&*u.daemon_http_base, "http://127.0.0.1:7070");
assert_eq!(&*u.daemon_ws_base, "ws://127.0.0.1:7070");
}
#[test]
fn ws_base_swaps_https_scheme() {
let u = UpstreamConfig::from_daemon_url("https://daemon.example/");
let u = UpstreamConfig::from_daemon_url("https://daemon.example/").unwrap();
assert_eq!(&*u.daemon_ws_base, "wss://daemon.example");
}
@@ -308,7 +343,7 @@ mod tests {
/// a bogus unreachable address; a 413 (not a 502) proves we bailed early.
#[tokio::test]
async fn oversized_request_body_is_rejected_with_413() {
let state = ProxyState::new(UpstreamConfig::from_daemon_url("http://127.0.0.1:9"));
let state = ProxyState::new(UpstreamConfig::from_daemon_url("http://127.0.0.1:9").unwrap());
let big = vec![0u8; MAX_REQUEST_BODY + 1];
let req = Request::builder()
.method("POST")
@@ -325,7 +360,7 @@ mod tests {
/// limit, not off-by-one against legitimate traffic.
#[tokio::test]
async fn body_at_cap_is_not_rejected_by_the_size_check() {
let state = ProxyState::new(UpstreamConfig::from_daemon_url("http://127.0.0.1:9"));
let state = ProxyState::new(UpstreamConfig::from_daemon_url("http://127.0.0.1:9").unwrap());
let body = vec![0u8; MAX_REQUEST_BODY];
let req = Request::builder()
.method("POST")
@@ -336,4 +371,49 @@ mod tests {
let resp = proxy_http(State(state), req).await;
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
/// #39: a daemon rejection carrying the structured error body surfaces its
/// `message` as the close reason, under an application close code, so the
/// browser can show *why* the attach failed instead of an opaque 1006.
#[test]
fn upstream_close_reason_extracts_daemon_message() {
use tokio_tungstenite::tungstenite::{http, Error as WsError};
let body =
br#"{"error":{"code":"unknown_session","message":"no such session on this daemon"}}"#
.to_vec();
let resp = http::Response::builder()
.status(404)
.body(Some(body))
.unwrap();
let (code, reason) = upstream_close_reason(&WsError::Http(resp));
assert_eq!(code, CLOSE_UPSTREAM_REJECTED);
assert_eq!(reason, "no such session on this daemon");
}
/// A non-HTTP failure (e.g. TCP refused — peer down) still yields a usable
/// generic reason rather than a panic or empty string.
#[test]
fn upstream_close_reason_falls_back_for_non_http_errors() {
use tokio_tungstenite::tungstenite::Error as WsError;
let e = WsError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"nope",
));
let (code, reason) = upstream_close_reason(&e);
assert_eq!(code, CLOSE_UPSTREAM_REJECTED);
assert!(!reason.is_empty());
}
/// Close reasons ride a control frame capped at 123 bytes; an over-long
/// message is truncated on a char boundary (no panic on multi-byte chars).
#[test]
fn truncate_reason_respects_control_frame_budget() {
let ascii = "x".repeat(200);
assert_eq!(truncate_reason(&ascii).len(), 123);
let multibyte = "é".repeat(100); // 2 bytes each
let out = truncate_reason(&multibyte);
assert!(out.len() <= 123);
assert!(out.is_char_boundary(out.len()));
}
}
+15 -7
View File
@@ -5,6 +5,7 @@ use axum::extract::{Request, State, WebSocketUpgrade};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get};
use axum::Router;
use maestro_protocol::routes::{self, UrlError};
use crate::config::WebConfig;
use crate::proxy::{pump_ws, upstream_ws_url, ProxyState, UpstreamConfig};
@@ -12,30 +13,37 @@ use crate::static_bundle;
/// Build the public router. The returned router is ready to hand to
/// `axum::serve`.
pub fn router(config: &WebConfig) -> Router {
let upstream = UpstreamConfig::from_daemon_url(&config.daemon_url);
pub fn router(config: &WebConfig) -> Result<Router, UrlError> {
let upstream = UpstreamConfig::from_daemon_url(&config.daemon_url)?;
let proxy_state = ProxyState::new(upstream);
// Mount each route at its real path (not under `nest`, which would strip
// the `/api` prefix before the proxy could forward it). WS routes go
// first so the HTTP fallback doesn't shadow them.
Router::new()
.route("/api/attach/:id", any(ws_proxy))
.route("/api/events", any(ws_proxy))
Ok(Router::new()
.route(routes::patterns::ATTACH, any(ws_proxy))
.route(routes::EVENTS, any(ws_proxy))
.route("/", get(serve_index))
.fallback(catch_all)
.with_state(proxy_state)
.with_state(proxy_state))
}
/// Default handler: `/api/*` goes to the daemon, anything else falls through
/// to the embedded SPA bundle (with SPA-style fallback to `index.html`).
async fn catch_all(State(state): State<ProxyState>, req: Request) -> Response {
if req.uri().path().starts_with("/api/") {
if is_api_path(req.uri().path()) {
return crate::proxy::proxy_http(State(state), req).await;
}
static_bundle::serve_path(req.uri().path())
}
/// Split point between the proxied daemon surface and the SPA bundle. A daemon
/// route added outside [`routes::API_PREFIX`] would silently be answered with
/// `index.html` instead of being forwarded.
fn is_api_path(path: &str) -> bool {
path.starts_with(&format!("{}/", routes::API_PREFIX))
}
async fn ws_proxy(State(state): State<ProxyState>, ws: WebSocketUpgrade, req: Request) -> Response {
let upstream_url = upstream_ws_url(&state, req.uri());
ws.on_upgrade(move |socket| async move {
+8 -8
View File
@@ -1,18 +1,18 @@
//! Embedded `web/dist` SPA bundle.
//! Embedded SPA bundle.
//!
//! The build of `web/` is consumed at compile time via `include_dir!`, which
//! requires the directory to exist. `build.rs` runs `pnpm build` before the
//! crate compiles, so `web/dist` is always present and populated — even on a
//! fresh checkout (`web/dist` is gitignored and never committed). The
//! placeholder below is therefore defensive only; a successful build always
//! embeds the real bundle.
//! The bundle is consumed at compile time via `include_dir!`, reading whatever
//! directory `build.rs` resolved into `MAESTRO_WEB_BUNDLE_DIR` — either the
//! `web/dist` it just built with pnpm, or a prebuilt bundle handed over in
//! `MAESTRO_WEB_DIST`. Either way `build.rs` fails loudly if that directory
//! holds no `index.html`, so the placeholder below is defensive only; a
//! successful build always embeds the real bundle.
use axum::body::Body;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use include_dir::{include_dir, Dir};
static BUNDLE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../web/dist");
static BUNDLE: Dir<'_> = include_dir!("$MAESTRO_WEB_BUNDLE_DIR");
/// Serve `/index.html`, falling back to a tiny placeholder when the bundle
/// hasn't been built yet. Used both for `GET /` and SPA-route fallthrough

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