Compare commits

...
Author SHA1 Message Date
Miguel Palhas 6af8abccd3 docs(maestro): add SIDEBAR.md session card contract 2026-08-24 19:17:04 +01:00
Miguel Palhas ad2e700391 fix(maestro): spawn opens a new tab instead of splitting
ci / web (push) Successful in 31s
ci / rust (push) Successful in 2m22s
ci / flake (push) Successful in 6m43s
Pressing Spawn while reading a session split that pane in half. It now
opens the draft in its own tab, leaving the current tab untouched.

Scoped to the explicit Spawn affordances (the header button and the
sidebar empty state, both via OPEN_SPAWN_EVENT). "Spawn similar" and the
fast-forward key deliberately keep splitting: both act on the session in
front of you, so a pane beside it is the point. Reusing an empty pane
(#72) is unchanged — that path never split anything.

Closes #100
2026-08-24 17:17:35 +01:00
Miguel Palhas 2dcef860a6 fix(maestro): group worktree sessions under their repository
The sidebar nested a worktree session under the worktree, so a repo grew
a <repo>/worktrees/<branch> folder holding one session of the same name,
and the repo stopped being where you look for the work.

The daemon recorded whatever base_repo the request named, which is the
worktree itself when spawning from inside one; it now records the
resolved main checkout. The web tree groups by that instead of the cwd.
The TUI sidebar is a flat list with no directory grouping, so it needs
no mirror.

Closes #99
2026-08-24 17:17:35 +01:00
Miguel Palhas 0bb5df7245 fix(maestro): write nix.conf before installing nix in CI
ci / web (push) Successful in 1m26s
ci / rust (push) Successful in 4m20s
ci / flake (push) Successful in 7m24s
The runner container is root with no `nixbld` group. The installer
writes `build-users-group = nixbld` regardless and then fails on it:

  error: the group 'nixbld' specified in 'build-users-group' does not exist

Writing the config first (and again after, in case the installer
replaced it) clears that and enables flakes in the same place.

Refs #98
2026-08-24 16:19:01 +01:00
Miguel Palhas cb070e4f84 fix(maestro): make the flake CI job work on the runner
ci / flake (push) Failing after 16s
ci / web (push) Successful in 37s
ci / rust (push) Successful in 5m9s
Two defects in the job as landed, both invisible locally:

- The multi-user installer needs systemd, which the act_runner container
  does not have. Single-user install instead.
- The installer does not enable flakes, so `nix build .#…` fails on a
  fresh install. A machine with them in nix.conf builds fine, which is
  why this passed locally and failed in CI.

Refs #98
2026-08-24 16:17:00 +01:00
Miguel Palhas 8fde211348 feat(maestro): spawn form v2 refinements and review fixes
ci / web (push) Successful in 26s
ci / flake (push) Failing after 21s
ci / rust (push) Successful in 2m30s
Closes the Spawn v2 milestone: six review findings from the first blitz
plus seven pieces of operator feedback on the draft pane, and a flake
build that had been silently broken.

Form:
- The draft form is capped and centred instead of stretching to the pane.
- The worktree section sits behind a collapsed "Advanced options"
  disclosure whose header says whether a worktree will be created.
- The two controls both labelled "Branch" are now "New branch name" and
  "Pick a branch", matching in both clients.
- Enter submits from any field; inside a picker it chooses first.

Directory picker, rebuilt in both clients from one written set of rules:
- Projects are boxes with full paths; worktrees are a second step that
  appears once a project is picked, with "+ new" as the default.
- Picking an existing worktree runs in it and leaves the create-worktree
  toggle off — the daemon never adopts, so running in a tree and owning
  it stay separate acts (docs/WORKTREES.md).

Fixes: the fast-forward spawn key never matched a shifted 'S'; openDraft
never reused an empty pane and dropped its seed; swapping draft panes
moved the data but not the forms; the gitroot probe fired per keystroke;
the projects rescan ignored its own TTL; a late /api/info reset a picked
preset; and CI now builds the flake packages, which had been failing
since the pnpmDeps hash went stale.
2026-08-24 16:13:17 +01:00
Miguel Palhas 31cdbd4f0e merge: Enter submits the draft form from any field
Closes #97
2026-08-24 15:52:28 +01:00
Miguel Palhas 0d06827b69 fix(maestro): keep Enter toggling the worktree checkbox, not submitting
Matches the TUI's WorktreeToggle, whose Enter also toggles rather than
submitting — otherwise the two clients would disagree on this one
field despite #97 requiring the same rule everywhere else.
2026-08-24 15:51:19 +01:00
Miguel Palhas 8a6cb81d3a feat(maestro): Enter submits the TUI draft pane from any field (#97)
DirPicker mirrors the web filter/picker split: typing narrows and the
next Enter submits with the highlighted project directly (nothing was
chosen yet); an explicit Up/Down move onto a different row is
mid-selection, so Enter there only chooses it and a further Enter (now
the active pick) submits. WorktreeChoice, BranchExisting, Preset and
BranchMode already apply their highlight eagerly on Up/Down/Left/Right,
so Enter always submits there. Advanced and WorktreeToggle keep
activating on Enter, matching the web form's own button controls.
2026-08-24 15:50:28 +01:00
Miguel Palhas 52f9d0679e merge: distinct labels for the worktree branch fields
Closes #94
2026-08-24 15:48:38 +01:00
Miguel Palhas e6ef236f89 fix(maestro): match the web form's distinct branch-field labels in the TUI (#94)
The draft pane's new-branch gutter was labelled "br:" in both branch
modes — since #94's web fix now says "New branch name" / "Pick a
branch", mirror the same distinction here: "new:" for the branch-name
input, "pick:" for the existing-branch list (which previously had no
label at all).
2026-08-24 15:44:06 +01:00
Miguel Palhas 5747c71800 fix(maestro): distinct labels for the worktree branch field (#94)
The new-branch name input and the existing-branch picker both had a
literal <label>Branch</label>, on top of "New branch"/"Existing branch"
mode radios — reading the form gave no clue what the field wanted.
Kept the mode radios (they name the choice, not the field) and gave
each field its own label: "New branch name" for the text input,
"Pick a branch" for the select.
2026-08-24 15:42:10 +01:00
Miguel Palhas 5b0a0c2001 feat(maestro): Enter submits the spawn form from any field (#97)
A project-box or worktree-choice picker button that isn't yet the
active pick takes the Enter to choose it instead; the next Enter, once
it's active, submits. The project filter resolves to the top filtered
project rather than sending whatever cwd was last set. The raw-cwd
autocomplete's own Enter (accept the highlight) keeps working via
e.defaultPrevented.
2026-08-24 15:38:44 +01:00
Miguel Palhas df9013ffdf merge: two-step worktree picker in the TUI draft
Keeps both #93 and #96 in the draft layout: the worktree toggle and body
stay behind the Advanced disclosure, while the worktree choice step
belongs to the directory picker (#95 rule 3) and stays visible once a
project is picked.

Closes #96
2026-08-24 15:30:02 +01:00
Miguel Palhas 39341b278c merge: collapse the worktree section by default in both clients
Closes #93
2026-08-24 15:27:38 +01:00
Miguel Palhas b646cc5adf Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/96-tui-picker 2026-08-24 15:25:29 +01:00
Miguel Palhas cfc125144f feat(maestro): two-step worktree picker in the TUI draft
Mirrors the web project-picker redesign (#95) in the TUI (#96): step one
lists one box per repository (name, full path); picking one reveals step
two — "+ new" then that repository's worktrees newest-first, defaulting
to "+ new" except on a box that is itself a linked worktree. "+ new"
creates (cwd is the repo, create-worktree on); an existing worktree runs
in it (cwd is that worktree, create-worktree off). Neither step probes
/api/gitroot — only the free-text "Other directory…" hatch does.

Replaces the flat indented project+worktree list from #91/#64 with
projects::group_projects-backed DraftGroup boxes; the filter still
matches a repository via its own name/path or any of its worktrees.
2026-08-24 15:25:27 +01:00
Miguel Palhas cd66dbf7d8 Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/93-collapse-worktree
# Conflicts:
#	web/src/spawnForm.ts
2026-08-24 15:22:16 +01:00
Miguel Palhas 6bb812f6bd feat(maestro): collapse the TUI draft's worktree section by default (#93)
Mirrors the web spawn form: worktree/branch controls sit behind a new
"Advanced options" disclosure line (bracket [+]/[-], toggled by Enter
or Space, closed by default) instead of always showing the checkbox
and branch rows. The header states whether a worktree will be created
so #91's off-by-default for a worktree row is visible without
expanding. Collapsed fields drop out of Tab order; expanding only adds
rows below the disclosure, never moving the ones above it.
2026-08-24 15:20:35 +01:00
Miguel Palhas d3098a9400 merge: build flake packages in CI so a stale pnpmDeps hash fails loudly
Closes #98
2026-08-24 15:16:30 +01:00
Miguel Palhas 5de04af36a feat(maestro): collapse the spawn form worktree section by default (#93)
Closed by default behind a disclosure reusing the sidebar folder-header
affordance (catalog.ts), since most spawns take the default target and
the section was the largest block in the form. The collapsed header
states whether a worktree will be created, so #91's off-by-default for
a worktree row stays visible without expanding.

Fixes a latent [hidden] + display:flex bug for the new .wt-collapsible
element (author CSS beats the UA [hidden] rule at equal specificity);
.wt-body has the same bug pre-existing and is out of scope here.
2026-08-24 15:15:47 +01:00
Miguel Palhas 36cb03961c ci(maestro): build flake packages so a stale pnpmDeps hash fails loudly
A web/pnpm-lock.yaml change invalidates the pinned pnpmDeps.hash in
flake.nix silently; nix build .#maestro-web then stays broken until
someone notices by hand (#98). Add a flake job that installs nix and
builds the packaged outputs on every CI run.
2026-08-24 15:14:43 +01:00
Miguel Palhas 5e34ce7a61 merge: pick a project, then its worktree
Closes #95
2026-08-24 15:10:27 +01:00
Miguel Palhas 1fa9ce798e Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/95-project-picker
# Conflicts:
#	web/src/spawnFormWorktree.test.ts
2026-08-24 15:09:05 +01:00
Miguel Palhas 2c89902829 feat(maestro): pick a project, then its worktree
The working-directory picker was a flat list mixing repositories with
their worktrees, each row repeating the project name as an abbreviated
path. It is now two steps: a grid of project boxes carrying the full
unabbreviated path, then — only once a project is picked — that
repository's worktrees with "+ new" as the default.

"+ new" creates a worktree; picking an existing one runs in it and
leaves the create-worktree toggle off, since the daemon never adopts
(docs/WORKTREES.md). That is #91's rule, now an explicit choice rather
than an inference from which row was clicked.

Also fixes `.raw-wrap[hidden]`: `display: flex` outranked the UA sheet's
`[hidden]`, so the free-text cwd input showed even when hidden.

The interaction rules are listed in spawnForm.ts for the TUI mirror (#96)
to follow.

Refs #95
2026-08-24 15:08:05 +01:00
Miguel Palhas 8949fdfdf6 merge: debounce the gitroot probe in both clients
Closes #74
2026-08-24 15:06:50 +01:00
Miguel Palhas 596e7e2f49 feat(maestro): debounce the gitroot probe in the TUI draft
Typing in the raw path field fired one /api/gitroot probe per
keystroke (#74). The draft now schedules the probe 250ms out — matching
GITROOT_PROBE_DEBOUNCE_MS in web/src/spawnForm.ts — and every keystroke
resets it; the 30Hz heartbeat polls for due probes so the burst's one
request fires after typing stops. The seq guard in resolve_gitroot is
untouched, so a late answer still cannot overwrite a newer input.
2026-08-24 15:04:47 +01:00
Miguel Palhas 16609eff9e feat(maestro): debounce the gitroot probe in the spawn form
Typing in the raw cwd field fired one /api/gitroot request per
keystroke (#74). Every keystroke now resets a 250ms window shared with
the TUI draft (GITROOT_PROBE_DEBOUNCE_MS there); probeToken still owns
ordering so a late answer can't overwrite a newer input.
2026-08-24 14:59:56 +01:00
Miguel Palhas 9b0e9751ec merge: stop a late /api/info resolution from clobbering a picked preset
Closes #76
2026-08-24 14:58:53 +01:00
Miguel Palhas d1ed4f1fdc Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/76-preset-reset 2026-08-24 14:58:19 +01:00
Miguel Palhas 2c8758ed14 fix(maestro): stop a late /api/info resolution from clobbering a picked preset
A tile clicked, or a custom command typed, during the /api/info round trip
was discarded: applyInitialCommand() ran unconditionally a second time once
presets refined, re-selecting the first non-custom preset with no seed to
protect the operator's pick. Guard it with a commandTouched flag, mirroring
the nameTouched pattern already used for the name-batch race.

Fixes #76.
2026-08-24 14:58:07 +01:00
Miguel Palhas 2a2da7f779 merge: move draft forms along with swapped pane data
Closes #73
2026-08-24 14:55:55 +01:00
Miguel Palhas 5eab434647 merge: default the worktree toggle off in an existing worktree
Closes #91
2026-08-24 14:54:46 +01:00
Miguel Palhas 8e9ed580ea Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/73-draft-pane-swap 2026-08-24 14:54:26 +01:00
Miguel Palhas 2d5e2f1718 fix(maestro): move draft forms along with swapped pane data
Center-zone pane drag swapped dragged.draft/target.draft but left
draftForms cached under the old leaf id, so both panes kept showing
their pre-swap form values while writing into the swapped leaf.

Tear down both leaves' cached forms at swap time (same pattern as
openDraft's stale-form teardown, #72) so render() remounts fresh
forms seeded from the swapped data.

Fixes #73
2026-08-24 14:54:18 +01:00
Miguel Palhas b80d604408 fix(maestro): default the worktree toggle off when the directory is itself a worktree (#91)
Picking a project row that is itself a linked git worktree used to
default 'Create git worktree' on, so spawning asked for a second
worktree of an already-checked-out branch and git refused. Both clients
now default the toggle off there: the session just runs in that existing
worktree with no worktree record (not adoption). Turning it back on by
hand still tries, per the issue's non-goal.
2026-08-24 14:52:17 +01:00
Miguel Palhas 56d1afe9bd merge: cap and centre the spawn form's width
Closes #92
2026-08-24 14:48:45 +01:00
Miguel Palhas 73e9ecfc85 fix(maestro): cap and centre the spawn form's width
The form filled the full pane width, so on a 4K display every input
stretched thousands of pixels wide. Bound it to 60ch (in line with the
48ch/42ch precedents already in the type scale) and centre it with
margin auto; panes narrower than the cap still fill edge to edge.
2026-08-24 14:48:19 +01:00
Miguel Palhas 095f810176 merge: make shift-S resolve to the fast-forward spawn binding
Closes #70
2026-08-24 14:45:53 +01:00
Miguel Palhas 3e809db1bc merge: honour the project cache TTL in the background rescan
Closes #75
2026-08-24 14:42:54 +01:00
Miguel Palhas e8bd8a4952 merge: reuse an empty draft pane and keep its seed
Closes #72
2026-08-24 14:41:42 +01:00
Miguel PalhasandClaude Sonnet 5 a85773cef0 fix(maestro): make shift-S resolve to the fast-forward spawn binding
crossterm attaches KeyModifiers::SHIFT to every uppercase char, so a real
Shift+S delivered Char { c: 'S', shift: true }. canonical() turned that
into "shift-S", which the default "S" binding never matched, so
FastForwardSpawn was unreachable from the keyboard.

canonical() now drops the shift- prefix when the char is already
uppercase, since the case already says "shifted". Regression test drives
an actual crossterm KeyEvent through canonicalise() and the keymap,
rather than asserting on the binding string, per #70.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 14:39:53 +01:00
Miguel Palhas 9c6e06ec4f fix(maestro): reuse empty draft pane and keep its seed (#72)
openDraft's reuse check was `!target.draft`, dead after first render:
createSpawnForm auto-syncs its picked defaults into the leaf's draft
once its project load settles, so every empty pane has a non-null
draft a second later. `n` and Spawn always split instead of reusing
the pane on screen.

Reuse now checks only `!target.sessionId`, matching the codebase's own
definition of "empty" pane elsewhere. Reusing also drops the leaf's
stale form instance so a new seed is read by the rebuilt form instead
of being ignored by the old one.
2026-08-24 14:39:51 +01:00
Miguel Palhas 3029de667e fix(maestro): honour the project cache TTL in the background rescan (#75) 2026-08-24 14:39:10 +01:00
Miguel Palhas a8d693c0a3 fix(web): bump pnpmDeps hash for lockfile change
ci / web (push) Successful in 26s
ci / rust (push) Successful in 3m8s
2026-08-24 13:57:01 +01:00
Miguel Palhas 6c1bcf042c feat(maestro): spawn v2 and the worktree lifecycle redesign
ci / web (push) Successful in 25s
ci / rust (push) Failing after 1m40s
Merges two milestones. Spawn v2 (#53-#68) replaced the spawn modal with
an inline draft pane, added project scanning, branch pickers and name
generation across the web and TUI clients.

Worktree lifecycle (#77-#89) then redesigned what the daemon does with a
worktree it created, which is what Spawn v2's reviews kept getting wrong:

- The daemon never adopts a directory it finds. A path collision is
  sidestepped with a numeric suffix instead of resolved.
- A kill is refused when the worktree holds unsaved work; ignored files
  never count. --force ends the session and RETAINS the tree.
- A session that exits on its own applies the same predicate, from the
  same function, so the two paths cannot drift.
- Retained trees are persisted in state_dir, listed by GET /api/worktrees,
  and reclaimed explicitly. Reclaim removes the directory, never the branch.
- cleanup_on_kill is gone from the wire; what survives is decided by what
  is in the tree, not by what the spawn asked for.

docs/WORKTREES.md is the written contract, with the reasoning for each
non-obvious rule.
2026-08-24 13:36:53 +01:00
Miguel Palhas fb87a00906 merge: surface blocked work before the kill is attempted
Closes #83
2026-08-24 13:22:50 +01:00
Miguel Palhas d4dcfe7f00 feat(maestro): show blocked work on session rows and warn on kill in the web UI (#83) 2026-08-24 13:19:12 +01:00
Miguel Palhas 6161cabe77 feat(maestro): warn on kill and mark blocked sessions in the TUI (#83) 2026-08-24 13:08:13 +01:00
Miguel Palhas 524a848fc6 feat(maestro): show blocked work in the CLI table and warn on kill (#83) 2026-08-24 13:06:43 +01:00
Miguel Palhas 187bcb01d6 feat(maestro): refresh the blocked-work indicator in the watcher (#83)
run_blocked_work_pass rides the liveness tick but probes on a slower
cadence (DEFAULT_BLOCKED_WORK_INTERVAL, 15s): each probe is three git
subprocesses per session. Sessions without a worktree are filtered out
before any probe, so their indicator costs nothing. A failed probe keeps
the previous value; settled exits clear it.
2026-08-24 13:04:30 +01:00
Miguel Palhas 003e3839bc feat(maestro): carry a blocked-work indicator on Session (#83)
Session.blocked_work is Option<InTheWay>, refreshed by the daemon's
watcher; None for worktree-less sessions and until first probed.
Defaults on the wire so older payloads stay forward-compatible.
2026-08-24 12:58:16 +01:00
Miguel Palhas d2079cac3a merge: e2e coverage for the whole worktree lifecycle
Resolves the e2e conflict in favour of #88's helper, rewritten as a
struct literal now that cleanup_on_kill is gone — #88 had built the
spec through serde_json to dodge the field, which cost compile-time
checking for no reason once the field no longer exists.

Closes #88
2026-08-24 12:37:41 +01:00
Miguel Palhas 2083911baa test(maestro): cover the whole worktree lifecycle end to end (#88)
Every scenario from the contract, against a real daemon, tmux and git:
collision suffixes with both trees working, foreign directories
sidestepped without adoption, the guarded kill and its refusal
breakdown, save-then-kill succeeding, --force retaining the tree,
natural exits settling dirty and pristine worktrees differently, the
retained list with reasons, reclaim keeping the branch, and the store
surviving a daemon restart.

Worktree specs are built through their wire shape so the tests carry no
reference to WorktreeSpec.cleanup_on_kill, which #87 is removing.
2026-08-24 12:33:39 +01:00
Miguel Palhas b5450651d1 test(maestro): expose forced kill and retained-worktree calls in the e2e harness 2026-08-24 12:33:39 +01:00
Miguel Palhas 8aa007c73b fix(maestro): drop the cleanup_on_kill call sites #87 could not reach
Three in session_manager.rs arrived with #82 after #87 branched; four in
maestro-e2e were fenced off because #88 owns that crate. Removing the
field without them left the workspace uncompilable.
2026-08-24 12:12:02 +01:00
Miguel Palhas f4d12fa02d merge: drop cleanup_on_kill from the wire and the CLI
Closes #87
2026-08-24 12:11:31 +01:00
Miguel Palhas 31702e03e3 refactor(maestro): drop cleanup_on_kill from the wire and the CLI
Nothing has honoured WorktreeSpec.cleanup_on_kill since #56, and after
#79 and #81 survival is decided by what is in a worktree, not by what
the spawn asked for. A field that lies is worse than a missing one.

Removes the field from the protocol, the --cleanup-on-kill flag from
the CLI, and the tests asserting its default.
2026-08-24 12:08:41 +01:00
Miguel Palhas dde8dd8cfc merge: apply the worktree rule when a session exits on its own
Closes #82
2026-08-24 11:57:51 +01:00
Miguel Palhas 2fc19a886e feat(maestro): apply the kill's worktree rule when a session exits on its own
When the liveness watcher finds the tmux session gone, it now settles
the worktree through the same probe_worktree/record_retention helpers
the kill uses: pristine trees are removed, anything else is retained as
exited-with-unsaved-work before anything is torn down. The dead session
drops its worktree reference once settled, so dismissing it neither
refuses nor retains twice. If the probe or store write fails, nothing
is destroyed and the tree stays attached to the record.

WORKTREES.md: written down the settle-then-drop and nothing-destroyed-
when-unrecordable rules alongside the existing exit rule.
2026-08-24 11:54:43 +01:00
Miguel Palhas 0b95d11aa6 Merge remote-tracking branch 'origin/blitz/worktree-lifecycle' into wl/82-natural-exit 2026-08-24 11:54:11 +01:00
Miguel Palhas a85084d0d9 refactor(maestro): extract shared probe and retain helpers from the kill path
probe_worktree and record_retention will be reused by the natural-exit
path (#82); kill's behavior is unchanged.
2026-08-24 11:49:36 +01:00
Miguel Palhas 5282ba3411 merge: web retained-worktrees view with reclaim
Closes #84
2026-08-24 11:45:34 +01:00
Miguel Palhas 6f698df9f4 feat(maestro): retained-worktrees view with reclaim (#84) 2026-08-24 11:44:22 +01:00
Miguel Palhas 8e8540460f feat(maestro): web wire types + client calls for retained worktrees 2026-08-24 11:44:22 +01:00
Miguel Palhas 2d796a3556 merge: kill --force ends the session and retains the worktree
Closes #81
2026-08-24 11:39:28 +01:00
Miguel Palhas 1984bf9472 merge: tui retained-worktrees view with reclaim
Closes #85
2026-08-24 11:36:08 +01:00
Miguel Palhas f67803acf5 Merge remote-tracking branch 'origin/blitz/worktree-lifecycle' into wl/81-force-retains
# Conflicts:
#	crates/maestro-protocol/src/client.rs
2026-08-24 11:35:08 +01:00
Miguel Palhas 0c135aa065 feat(maestro): maestro kill --force
The flag overrides the kill refusal (#81): the session ends, the guarded
worktree is retained, and the CLI prints where the work went — path,
branch, force-killed — instead of leaving the operator to wonder.

WORKTREES.md: write down that a forced kill of a tree whose guard is
clear removes it as usual — force changes what happens to blocked work,
not ordinary cleanup.
2026-08-24 11:34:15 +01:00
Miguel Palhas 56c8e35d6f feat(maestro): wire worktrees view into the tui loop (#85)
w opens the view; r confirms reclaim and says the branch survives.
The in-the-way summary uses InTheWay's Display impl so the TUI cannot
drift from the CLI's wording.
2026-08-24 11:32:22 +01:00
Miguel Palhas b96f18c7cb feat(maestro): forced kill ends the session and retains the worktree
--force overrides the kill refusal and nothing else (#81). The guarded
worktree is recorded in the retained store with reason force-killed —
never removed — and the outcome names what was kept so a client can say
where the work went. A tree whose guard is clear never needed the
override; force removes it as usual.

The store write happens before teardown, so a failed retention leaves
the session running and every artifact intact. DELETE /api/sessions/:id
takes ?force=true and answers 200 with KillOutcome; the unforced kill
keeps its 204.
2026-08-24 11:29:45 +01:00
Miguel Palhas 0166607064 feat(maestro): retained-worktrees view overlay (#85) 2026-08-24 11:26:56 +01:00
Miguel Palhas bb2f439302 feat(maestro): kill outcome payload and force query in protocol
DELETE /api/sessions/:id gains a force query param (#81). A forced kill
answers 200 with KillOutcome, naming what was retained and why; the
unforced kill keeps its 204. DaemonClient::kill_session takes the flag.
2026-08-24 11:20:00 +01:00
Miguel Palhas 58b4257c92 merge: maestro worktrees list / reclaim
Closes #86
2026-08-24 11:18:17 +01:00
Miguel Palhas 86443d195e merge: base update from blitz/worktree-lifecycle 2026-08-24 11:14:12 +01:00
Miguel Palhas abaa498b9f feat(maestro): worktrees list / reclaim CLI (#86)
maestro worktrees list mirrors the sessions table shape: hostname header,
home-abbreviated paths, and the in-the-way breakdown recomputed by the
daemon at read time. reclaim takes a required explicit ID — no
reclaim-everything — and its confirmation says the branch survives.
2026-08-24 11:13:55 +01:00
Miguel Palhas 62581d9f85 merge: refuse a kill when the worktree holds unsaved work
Collapses the two probes #79 and #80 grew in parallel into one:
worktree_probe::in_the_way is the single predicate behind the kill
refusal, the retained list, and the natural-exit classifier to come.
Keeps #79's in-worktree resolution (detached HEAD, missing directory)
and #80's InTheWay payload, and drops the duplicate UnsavedWork type.

Closes #79
2026-08-24 11:09:24 +01:00
Miguel Palhas 267f8a6712 feat(maestro): DaemonClient calls for retained worktrees 2026-08-24 11:06:30 +01:00
Miguel Palhas a575b8bef1 feat(maestro): refuse a kill when the worktree holds unsaved work (#79)
The guard moves off the worktree and onto the kill. DELETE
/api/sessions/:id now runs the shared unsaved_work probe before tearing
anything down and answers 409 kill_guarded with the breakdown on the
wire, so a client can say "3 uncommitted files, 2 unpushed commits".
The session keeps running and the tree is untouched.

WorktreeManager::remove is unconditional now — git itself still refuses
a dirty tree as a last line of defense.
2026-08-24 11:03:43 +01:00
Miguel Palhas e9e0136a89 merge: /api/worktrees list and reclaim
Closes #80
2026-08-24 11:02:13 +01:00
Miguel Palhas 4921f62796 feat(maestro): GET /api/worktrees and DELETE reclaim endpoints 2026-08-24 10:59:14 +01:00
Miguel Palhas 898c3cc763 feat(maestro): shared unsaved-work probe for worktrees (#79)
One function, unsaved_work(), returns the structured breakdown —
uncommitted tracked changes, untracked non-ignored files, unpushed
commits — so the kill guard and the natural-exit classifier (#82)
answer from the same place. Ignored files never count: every Rust
checkout has a target/, and counting those would leave no session
killable.

WorktreeManager::remove drops its own dirty-tree guard; git itself
still refuses a dirty tree, and the real guard moves onto the kill.
2026-08-24 10:59:08 +01:00
Miguel Palhas 44b9745e8c feat(maestro): read-side in-the-way probe and guard-free reclaim 2026-08-24 10:53:38 +01:00
Miguel Palhas cc8af94254 feat(maestro): worktree routes and retained-worktree payload in protocol 2026-08-24 10:48:12 +01:00
Miguel Palhas 50a9d35f62 merge: never adopt, sidestep collisions with a suffix
Closes #77
2026-08-24 10:41:17 +01:00
Miguel Palhas a9ef3ff97b merge: retained-worktree store
Closes #78
2026-08-24 10:39:08 +01:00
Miguel Palhas 0457a61a61 refactor(maestro): sidestep worktree path collisions with a numeric suffix
Anything occupying <repo>/worktrees/<branch> — one of ours, one the
operator made, an unrelated directory — is now bypassed by appending
-2, -3, ... to the directory; the branch stays as requested. The same
suffix applies under the [daemon].worktree_root override.

The daemon no longer asks whether an occupant is "ours": delete
registered_for(), the adoption branch in run_add and the
SessionManager::create live-session check, plus WorktreeError::PathExists.
A branch already checked out elsewhere is git's refusal to surface,
already marked by the branch picker's is_head/worktree flags.
2026-08-24 10:38:32 +01:00
Miguel Palhas 132b606830 feat(maestro): retained-worktree store in state_dir (#78)
Per-entry JSON files under <state_dir>/retained-worktrees/, written like
sidecar records: atomic tmp+rename, missing/malformed files tolerated on
read. Entries carry path, base repo, branch, reason (force-killed,
exited-with-unsaved-work), timestamp; they leave on reclaim or when
prune_missing notices the directory was removed by hand.
2026-08-24 10:35:26 +01:00
Miguel Palhas 35c75f9238 docs(maestro): write the worktree lifecycle contract
Closes #89
2026-08-24 10:29:42 +01:00
Miguel Palhas bbbc24a4aa fix(maestro): fifth-review findings, incl. the guarded-worktree dead end
The cleanup guard plus deterministic paths had created a trap: one
untracked file an agent left behind — a log, a .env, a stray artifact —
made the guard refuse removal, the directory stayed, and every later
spawn of that branch hit PathExists. The branch was burned permanently
and the product offered no way to clear it. A leftover tree registered
for the same branch is now adopted, so the operator lands back in their
own unsaved work. A tree a *live* session holds is still rejected, since
two sessions must not share a checkout.

Also:

- Fast-forward still awaited /api/info, /api/projects and /api/names
  inline, the exact freeze the draft loaders were restructured to avoid.
  It posts to the same channel now.
- Draft updates were routed by tab index, which shifts when a tab closes:
  an in-flight load could be dropped or land on the wrong pane. Drafts
  carry an id and are found by it.
- extend_names left the name it had just displayed at the head of the
  queue, so the next reroll produced it again and the key looked dead.
- The fast-forward fallback discarded info.home_dir, so its draft showed
  unabbreviated paths and seeded "Other directory…" with / — disagreeing
  with the normal draft entry point.
- Web fast-forward mounted via openSession, which replaces the focused
  pane: pressing it while attached evicted that session from the layout.
  It reuses an empty pane or splits, as the TUI does.
- branchEdited was inferred from the seeded branch value, but in "new"
  mode that value is the auto-synced session name — so any form rebuild
  froze the branch against later rerolls.
- resolveBaseRepo ignored the worktree checkbox, rebuilding the fieldset
  expanded under an unticked box and probing gitroot for a worktree the
  operator had turned off.
- short_hash used DefaultHasher, whose algorithm is not stable across
  Rust releases, in a directory name — a toolchain bump would orphan
  every worktree under the override root. Now FNV-1a.
2026-08-24 00:28:50 +01:00
Miguel Palhas e9368bb242 fix(maestro): fourth-review findings across daemon, tui and web
The existing-branch picker could never succeed in either client: both
treated the main checkout's HEAD as available, and since that is usually
`main` it became the default pick — git then refuses the second checkout.
A TUI test had baked the bug in by asserting the selection lands on the
`is_head` entry. Both pickers now treat "checked out anywhere" as held.

Worktree paths were still not injective. Flattening `/` and `.` to `-`
made `feat/x`, `feat.x` and `feat-x` share one directory, and the
override root keyed on the repo basename alone, so `~/a/maestro` and
`~/b/maestro` collided. A branch name is already a valid relative path —
git stores it as refs/heads/feat/example — so it is used verbatim and
nests under `worktrees/feat/`; the override root gains a digest of the
full repo path. Git forbids holding both `feat` and `feat/x`, so the
nesting cannot collide for valid refs.

Also:

- Recovered sessions dropped their worktree association, so after a
  restart a kill cleaned up nothing and the leftover directory blocked
  that branch forever. It is persisted on Session now.
- The nesting guard compared git's resolved paths against an unresolved
  one, so it silently passed on any symlinked repo path — and that guard
  is what stands between a kill and an inner tree's uncommitted work.
- Draft loads ran on the TUI event loop: /api/projects on a cold cache
  walks all of $HOME, freezing keys, frames and attached panes for up to
  the request timeout. They are detached tasks posting to a channel now.
- Fast-forward in the web client created the session and never mounted
  it, so `N` looked like a dead key while the TUI attached.
- The fast-forward fallback seed forced the worktree toggle off, against
  both a normal draft and the TUI.
- The projects background refresh honoured the TTL, so two of every three
  ticks were no-ops and requests paid for the rescan instead.
2026-08-24 00:10:35 +01:00
Miguel Palhas 918609574e merge: fast-forward spawn in both clients (#67) 2026-08-23 23:45:18 +01:00
Miguel Palhas bcb365f278 merge: blitz/spawn-v2 into spawn-v2/67-fastforward 2026-08-23 23:39:42 +01:00
Miguel Palhas 586c243056 feat(maestro): bind N to fast-forward spawn in the web client (#67) 2026-08-23 23:39:30 +01:00
Miguel Palhas 1647e64aed feat(maestro): fast-forward spawn resolution for the web client (#67) 2026-08-23 23:37:57 +01:00
Miguel Palhas 5aedaf838a feat(maestro): bind S to fast-forward spawn in the TUI (#67) 2026-08-23 23:03:46 +01:00
Miguel Palhas d922cdaa32 feat(maestro): fast-forward spawn default resolution (#67) 2026-08-23 22:59:26 +01:00
Miguel Palhas b4e57d1a4f merge: spawn-v2 e2e coverage (#66) 2026-08-23 22:58:43 +01:00
Miguel Palhas 74967e45b4 Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/66-e2e 2026-08-23 22:56:01 +01:00
Miguel Palhas 7a2cc30bf9 test(maestro): cover spawn-v2 discovery, worktrees and cleanup e2e (#66) 2026-08-23 22:55:52 +01:00
Miguel Palhas 8ed44e785d test(maestro): add branch/project clients and config seeding to e2e harness 2026-08-23 22:55:52 +01:00
Miguel Palhas 63da845614 merge: tui draft worktree section (#65) 2026-08-23 22:49:57 +01:00
Miguel Palhas d336bd802d Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/65-tui-worktree 2026-08-23 22:45:58 +01:00
Miguel Palhas 67d17f48d0 feat(maestro): app loop serves the draft's worktree fetches (#65)
Two new effects: ProbeGitRoot resolves the draft target's base repo via
/api/gitroot (seq-guarded, a miss unticks the toggle) and LoadBranches
fills the existing-branch picker from /api/branches, both applied back
to the tab that requested them. Branch loads triggered by a successful
probe are served inline so perform() stays recursion-free.
2026-08-23 22:45:49 +01:00
Miguel Palhas ec45312400 merge: reuse draft forms across renders (#68) 2026-08-23 22:45:20 +01:00
Miguel Palhas 525dc9a04a fix(maestro): reuse draft forms across renders (#68)
render() destroyed and rebuilt every draft form, so an unrelated
session exiting tore down a draft mid-edit: focus, caret and
selection were lost and /api/info, /api/projects, /api/names were
refetched for a form already on screen.

Key draft forms by leaf id; rebuild only the pane DOM around them
and destroy a form when its leaf is removed or filled by a session.
2026-08-23 22:43:42 +01:00
Miguel Palhas 264332266c feat(maestro): tui draft form gains a worktree section (#65)
Worktree on by default over a git repository with Enter/Space to spawn
in the checkout itself; base repo inferred from picked projects or a
guarded /api/gitroot probe for raw paths (a miss unticks, web parity);
new-branch name tracking the generated session name until touched;
Left/Right switches to picking an existing branch from GET /api/branches,
with held branches skipped and rendered dimmed beside their holder.
No cleanup option: cleanup is unconditional daemon-side (#56).
2026-08-23 22:42:46 +01:00
Miguel Palhas 37338def71 merge: delete the spawn modal, amend DESIGN.md (#62) 2026-08-23 22:38:53 +01:00
Miguel Palhas 51f91b3f5d docs(maestro): amend DESIGN.md for draft panes (#62)
The spawn modal is no longer one of the three surfaces: the Components
section now describes the draft pane, and the stale ≥600px
bottom-sheet→dialog breakpoint note is dropped.
2026-08-23 22:36:51 +01:00
Miguel Palhas 5c3f9cab9f feat(maestro): replace spawn modal with draft panes (#62)
The draft pane covers every field the modal had; the modal was a second
code path for the same job. The header Spawn button, sidebar empty state,
welcome button, and ?mock&spawn now open a draft pane via
MultiView.openDraft — reusing an empty focused pane, else splitting it.
"Spawn similar" seeds a draft with the session's cwd + command.

Modal-only CSS (backdrop, sheet/dialog chrome, keyframes) is deleted;
.modal-body form styling folds into .spawn-form, which also picks up the
select styling the worktree branch picker was borrowing from the modal.
2026-08-23 22:36:48 +01:00
Miguel Palhas 117f371c41 merge: tui draft form presets, project picker, names, validation (#64) 2026-08-23 22:02:17 +01:00
Miguel Palhas ad690ab2fa feat(maestro): tui draft form gains presets, project picker, names, validation (#64) 2026-08-23 22:01:53 +01:00
Miguel Palhas 7dcb87cd6e feat(maestro): project-list helpers for the tui draft picker (#64) 2026-08-23 21:46:54 +01:00
Miguel Palhas 26b0fa5e8a merge: worktree on by default, new-or-existing branch picker (#61) 2026-08-23 21:36:40 +01:00
Miguel Palhas 8b3c5d73b6 fix(maestro): third-review findings — nested worktrees, staleness, placement
The one that loses data: the project picker lists linked worktrees as
pickable rows, so `base_repo` was easily a worktree rather than the main
checkout, and the new placement then nested the tree inside it. Git hides
a registered worktree's contents from the outer `git status`, so the
outer tree read clean and removing it deleted the inner one's uncommitted
work — while that session could still be running. Placement now resolves
to the main checkout via `--git-common-dir`, and removal refuses outright
when `worktree list` reports anything registered underneath.

Also:

- The `worktree_root` override dropped the old UUID without replacing it,
  so two repos spawning the same branch name targeted one directory and
  the second failed. The repo basename now sits between root and slug.
- Project staleness read `.git/HEAD`'s mtime, which only moves on
  checkout. A repo committed to daily from one long-lived branch reported
  its last branch switch and got dimmed. Prefer `logs/HEAD`.
- The projects cache TTL (5s) was shorter than its own background refresh
  (60s), so nearly every request rescanned all of $HOME under the scanner
  lock — and draft panes re-request on unrelated re-renders. Now 180s.
- onSuccess resolved the leaf against the active tab only, so switching
  tabs mid-POST orphaned the session. Searches every tab.
2026-08-23 21:36:29 +01:00
Miguel Palhas ca4f7cb1d5 feat(maestro): worktree on by default with new-or-existing branch picker (#61)
- toggle defaults on for any target the project scan reports (repo or
  worktree); only the free-text escape hatch pays for /api/gitroot
- branch mode control switches between creating a new branch (named
  after the session name, as before) and picking an existing one from
  GET /api/branches; branches held by another worktree stay visible but
  disabled, labelled with that worktree's path
- emits create_branch: false on the wire for existing-mode picks
- branch-list load follows the form's ready/destroyed discipline so a
  late response cannot corrupt a rebuilt draft
2026-08-23 21:34:35 +01:00
Miguel Palhas cc1b3d45b8 merge: maestro-names crate and GET /api/names (#53)
# Conflicts:
#	crates/maestro-daemon/src/local_api.rs
#	crates/maestro-protocol/src/routes.rs
2026-08-23 21:20:08 +01:00
Miguel Palhas f7a8dcae76 merge: GET /api/branches with worktree occupancy (#55) 2026-08-23 21:17:45 +01:00
Miguel Palhas 818e0cc2db feat(maestro): spawn form rerolls from a fetched name batch (#53) 2026-08-23 21:15:33 +01:00
Miguel Palhas 23f01233b2 merge: blitz/spawn-v2 into spawn-v2/55-branches 2026-08-23 21:14:29 +01:00
Miguel Palhas 2a84e07f7d feat(maestro): GET /api/branches — local branches and worktree occupancy (#55) 2026-08-23 21:14:16 +01:00
Miguel Palhas a970ba89c2 feat(maestro): GET /api/names serves name batches (#53) 2026-08-23 21:13:46 +01:00
Miguel Palhas 17796c1ffa feat(maestro): maestro-names crate with musical wordlists (#53) 2026-08-23 21:11:06 +01:00
Miguel Palhas 01105f9424 merge: project list replaces the recents picker (#59) 2026-08-23 21:07:14 +01:00
Miguel Palhas b9829bc56f feat(maestro): project list replaces recents picker in spawn form (#59) 2026-08-23 21:06:10 +01:00
Miguel Palhas 9f582263b8 feat(maestro): project list sort/group/filter helpers (#59) 2026-08-23 21:02:16 +01:00
Miguel Palhas b5cbf94fa8 merge: project discovery scanner and GET /api/projects (#54) 2026-08-23 20:54:55 +01:00
Miguel Palhas 8376122dfe fix(maestro): second-review findings across daemon, tui and web
Ten findings from reviewing the accumulated blitz/spawn-v2 diff. The two
that could wedge a repo:

- The cleanup guard refused any worktree whose commits were on no other
  ref, which is every ordinary unpushed branch. Paths are deterministic
  now, so the leftover tree blocked the next spawn and each branch became
  a one-shot. `worktree remove` keeps refs/heads/<branch>, so committed
  work is never at risk: guard on an unclean working tree alone.
- A worktree whose directory had vanished failed the git probe and was
  reported as unsaved work, stranding the registration forever. Prune it
  instead, and prune before `worktree add` tests the target path.

Also:

- The CLI hardcoded create_branch: true, so the daemon's existing-branch
  path was unreachable. Added --existing-branch.
- Pastes were forwarded without their DECSET 2004 markers, so a remote
  shell ran a multi-line paste line by line.
- spawnForm emitted before its seed was restored and after destroy(), so
  a rebuilt draft blanked its cwd and a dead form clobbered the live one.
- showRawCwd and the probe's worktree untick never reached the leaf.
- A late autocomplete fetch reopened the popup under a blurred input.
- The "remove worktree when killed" checkbox promised something the
  daemon no longer honours; removed.
2026-08-23 20:54:21 +01:00
Miguel Palhas 4a814d5b17 feat(maestro): project discovery scanner and GET /api/projects (#54) 2026-08-23 20:48:59 +01:00
Miguel Palhas 1809f5d581 feat(maestro): uncapped spawn-history recents for project enrichment (#54) 2026-08-23 20:44:15 +01:00
Miguel Palhas ec51b0221c merge: dir autocomplete dismissal and key handling (#60) 2026-08-23 20:39:53 +01:00
Miguel Palhas ca585447ff fix(maestro): dir autocomplete dismissal and key handling
Escape closes the popup, refocuses the input and stops propagation so it
can't also discard a surrounding draft pane. Tab commits the highlighted
entry and lets focus move to the next field. Blur / clicking outside
dismisses, and a dismissed popup stays closed until the operator types
again.
2026-08-23 20:38:55 +01:00
Miguel Palhas 71301861c0 chore(maestro): add happy-dom for DOM-level web tests 2026-08-23 20:38:55 +01:00
Miguel Palhas 33fb50be6e feat(maestro): Project wire type and PROJECTS route (#54) 2026-08-23 20:32:29 +01:00
Miguel Palhas 2ce1dc43fe merge: tui inline draft pane + paste forwarding fix (#63) 2026-08-23 20:31:18 +01:00
Miguel Palhas b6cf87244f feat(maestro): [spawn].project_roots and project_scan_depth config (#54) 2026-08-23 20:30:59 +01:00
Miguel Palhas e99e21260b Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/63-tui-draft-pane 2026-08-23 20:26:26 +01:00
Miguel Palhas cea06f9204 fix(maestro): forward bracketed pastes into the attached session
forward_to_session dropped Paste(_) on the floor, so pastes into a
session vanished once terminal.rs enabled bracketed paste. Split the
arm: Paste forwards the payload verbatim (the remote readline
reassembles it), only Mouse events have no wire encoding.
2026-08-23 20:25:57 +01:00
Miguel Palhas 1199596528 feat(maestro): inline draft pane replaces the SpawnModal overlay
Action::OpenSpawnModal becomes NewDraftPane: pressing s turns the empty
pane into an inline draft form (or opens a new tab when the pane shows a
session). The form is a pane-level widget with its own field focus; a
successful submit replaces the draft with the new session in place,
matching the web client's draft panes. SpawnModal and OverlayKind::Spawn
are removed; kill-confirm and help overlays stay.
2026-08-23 20:25:46 +01:00
Miguel Palhas 6d21061799 merge: worktree placement, existing branches, guarded cleanup (#56) 2026-08-23 20:21:08 +01:00
Miguel Palhas 431f9def6b Merge branch 'blitz/spawn-v2' of https://git.naps.pt/naps62/maestro into spawn-v2/56-worktree-place 2026-08-23 20:17:21 +01:00
Miguel Palhas d8acc3adde feat(maestro): always clean up worktrees on kill behind a dirty-work guard 2026-08-23 20:17:08 +01:00
Miguel Palhas 95f0e42df5 feat(maestro): add draft-pane widget with its own field focus 2026-08-23 20:16:43 +01:00
Miguel Palhas 6f8de8de7c fix(maestro): draft-pane regressions from the integration review
Six findings from reviewing the accumulated blitz/spawn-v2 diff:

- loadRecents restored the seeded cwd but never emitted it, so the leaf's
  draft kept cwd:"" and the next render reseeded the form blank.
- stripDrafts did not recurse, so every half-filled draft in a split tab
  was written to localStorage; the old test passed only because hydrate
  discards draft on the way back in.
- showRawCwd and selectPreset grabbed focus from async init paths, so a
  draft pane stole the caret seconds after mounting.
- .mv-draft inherited no layout once the form left .modal-body.
- onSuccess mounted the new session onto a leaf that may have been closed
  while the POST was in flight, stranding it in no pane.
2026-08-23 20:12:28 +01:00
Miguel Palhas 018f44727c feat(maestro): place worktrees at <repo>/worktrees/<branch> 2026-08-23 20:09:38 +01:00
Miguel Palhas 85e7749e5a feat(maestro): worktree specs can check out an existing branch 2026-08-23 20:05:26 +01:00
Miguel Palhas 35bc5bfb40 merge: tui text-input widget (#58) 2026-08-23 20:00:47 +01:00
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
185 changed files with 23424 additions and 19813 deletions
-10
View File
@@ -1,10 +0,0 @@
target/
worktrees/
.git/
.github/
.claude/
docs/
web/node_modules/
web/dist/
*.md
.env*
+76
View File
@@ -52,3 +52,79 @@ 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
# The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile
# change invalidates the pin silently elsewhere, so the packaged build has to
# fail here too.
flake:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The runner container is root with no `nixbld` group, but the installer
# writes `build-users-group = nixbld` anyway and then fails on it, so the
# config goes in first. Flakes are off by default and `nix build .#…`
# needs them. Written again afterwards in case the installer replaced it.
- name: Install nix
run: |
mkdir -p /etc/nix
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
# The installer does not enable flakes, so `nix build .#…` needs the
# features passed explicitly — a local machine that has them in
# nix.conf will build fine while CI fails without this.
- name: build flake packages
run: |
nix --extra-experimental-features "nix-command flakes" build \
.#webFrontend \
.#maestro \
.#maestro-web \
--no-link
+10
View File
@@ -5,3 +5,13 @@ Cargo.lock.bak
# Local git worktrees for in-flight branches (never tracked)
worktrees/
# nix build output symlinks
/result
/result-*
# direnv (`use flake`)
/.direnv/
# `nix run .#dev` scratch state (tmux socket)
/.dev/
+53 -21
View File
@@ -1,54 +1,86 @@
# 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.
`docs/WORKTREES.md` is the contract for session worktrees — placement,
collisions, what blocks a kill, what `--force` retains, and what reclaim
touches. Read it before changing anything that creates or removes one.
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
sidebars — line layout, activity colours, duration format, state precedence.
Read it before changing sidebar rendering.
`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
+11 -147
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,10 @@ dependencies = [
"clap",
"dirs",
"futures-util",
"jsonwebtoken",
"libc",
"maestro-config",
"maestro-names",
"maestro-protocol",
"maestro-registry",
"reqwest",
"serde",
"serde_json",
@@ -1180,7 +1111,6 @@ dependencies = [
"thiserror 1.0.69",
"tokio",
"tokio-tungstenite 0.21.0",
"toml",
"tracing",
"tracing-subscriber",
"urlencoding",
@@ -1194,8 +1124,8 @@ dependencies = [
"chrono",
"futures-util",
"maestro-protocol",
"maestro-testing",
"maestro-tui",
"reqwest",
"serde",
"serde_json",
"tempfile",
@@ -1229,33 +1159,24 @@ 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,6 +1191,7 @@ dependencies = [
"futures-util",
"maestro-config",
"maestro-protocol",
"maestro-testing",
"ratatui",
"reqwest",
"serde_json",
@@ -1292,6 +1214,8 @@ dependencies = [
"futures-util",
"include_dir",
"maestro-config",
"maestro-protocol",
"maestro-testing",
"mime_guess",
"reqwest",
"serde_json",
@@ -1301,6 +1225,7 @@ dependencies = [
"tokio-tungstenite 0.21.0",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@@ -1373,12 +1298,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1456,12 +1375,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1490,22 +1403,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1707,8 +1604,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"cookie",
"cookie_store",
"futures-core",
"futures-util",
"http",
@@ -2165,37 +2060,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.3"
+2 -3
View File
@@ -16,16 +16,15 @@ 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"
+14 -14
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).
@@ -79,31 +79,31 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`.
`connecting` state pill pulses.
- Interaction: row hover/focus, button press (1px), input focus ring, chevron
rotation, theme-toggle sun/moon swap.
- Modal: bottom-sheet slide-up on mobile, scale-pop on ≥600px.
- Every animation has a `prefers-reduced-motion: reduce` path (pulses off,
transitions near-instant).
## Layout & structure
Mobile-first, structural responsiveness (not fluid type). Single `≤520px`
breakpoint stacks the header (full-width search, wrapped actions) 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
terminal atmosphere (very low contrast, not decoration).
breakpoint stacks the header (full-width search, wrapped actions). A faint
slate grid texture sits on `--bg` as terminal atmosphere (very low contrast,
not decoration).
## Components
Three surfaces, all DOM built in TS, styled by class:
- **Catalog** — sticky blurred header (pulsing wordmark, search, theme toggle,
Spawn); status bar (`N sessions · M live` tally + 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
ring; grouped worktree fieldset.
- **Draft pane** — the spawn form mounted inline in an empty split pane,
focusable like any session pane; mono inputs with green focus ring; grouped
worktree fieldset. Spawn opens one (reusing an empty focused pane, else
splitting), and "Spawn similar" seeds one from a session's dir + command.
Interactive elements ship default / hover / focus-visible / active / disabled
states; the accent green is used only for action, selection, focus, and live
@@ -112,5 +112,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 a draft pane),
`?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"]
+6 -1
View File
@@ -1,5 +1,10 @@
# Common dev / build entry points. Run `just` with no args to list.
# Run the full local stack — daemon on 127.0.0.1:7071 plus Vite on :5173,
# ordered and health-gated by process-compose. Ctrl-C stops everything.
dev:
nix run .#dev
# Build the maestro-web binary together with the SPA bundle it embeds.
web:
pnpm -C web install --frozen-lockfile
@@ -8,7 +13,7 @@ web:
# Run the web bundle in dev mode against a local daemon — Vite serves the
# frontend with HMR, and `/api/*` is proxied directly to the daemon at
# 127.0.0.1:7070 (or wherever $MAESTRO_REGISTRY points; see vite.config.ts).
# 127.0.0.1:7070 (see vite.config.ts).
web-dev:
pnpm -C web install
pnpm -C web dev
+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}");
}
}
+151 -34
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,48 @@ pub struct Cli {
#[derive(Debug, Subcommand)]
pub enum Command {
/// List all sessions, grouped by daemon.
/// List all sessions.
List(ListArgs),
/// Spawn a new session on a given daemon.
/// Spawn a new session.
Spawn(SpawnArgs),
/// Attach to a session over WebSocket. Bytes flow through the local TTY.
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// Set the default registry URL persisted in the CLI config.
SetRegistry(SetRegistryArgs),
/// List retained worktrees, or reclaim one by ID (#86).
Worktrees(WorktreesArgs),
}
/// `maestro worktrees <list|reclaim>`
#[derive(Debug, clap::Args)]
pub struct WorktreesArgs {
#[command(subcommand)]
pub command: WorktreesCommand,
}
#[derive(Debug, Subcommand)]
pub enum WorktreesCommand {
/// List retained worktrees and what is in the way of reclaiming each.
List(ListWorktreesArgs),
/// Reclaim one retained worktree: removes the directory, never the branch.
Reclaim(ReclaimArgs),
}
#[derive(Debug, clap::Args)]
pub struct ListWorktreesArgs {
/// Output as JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Debug, clap::Args)]
pub struct ReclaimArgs {
/// Retained worktree ID to reclaim. Required — there is no reclaim-all.
pub id: Uuid,
}
#[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 +82,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,
@@ -74,9 +97,11 @@ pub struct SpawnArgs {
/// Branch name for the worktree (required with --worktree-from).
#[arg(long)]
pub branch: Option<String>,
/// Remove the worktree when the session is killed.
/// Check out an existing branch instead of creating one. Without it the
/// daemon runs `worktree add -b`, which fails if the branch is already
/// there.
#[arg(long)]
pub cleanup_on_kill: bool,
pub existing_branch: bool,
/// After spawning, attach immediately.
#[arg(long, short = 'a')]
pub attach: bool,
@@ -95,12 +120,11 @@ pub struct AttachArgs {
pub struct KillArgs {
/// Session ID to kill.
pub session_id: Uuid,
}
#[derive(Debug, clap::Args)]
pub struct SetRegistryArgs {
/// Registry base URL to persist.
pub url: String,
/// End the session even when its worktree holds unsaved work (#81). The
/// worktree is retained — never removed — and enters the retained store;
/// the output says where the work went.
#[arg(long)]
pub force: bool,
}
fn parse_kv(s: &str) -> Result<(String, String), String> {
@@ -117,10 +141,9 @@ mod tests {
#[test]
fn parses_list() {
let cli = Cli::try_parse_from(["maestro", "list", "--daemon", "laptop", "--json"]).unwrap();
let cli = Cli::try_parse_from(["maestro", "list", "--json"]).unwrap();
match cli.command {
Command::List(args) => {
assert_eq!(args.daemon.as_deref(), Some("laptop"));
assert!(args.json);
}
_ => panic!("wrong subcommand"),
@@ -132,8 +155,6 @@ mod tests {
let cli = Cli::try_parse_from([
"maestro",
"spawn",
"--daemon",
"vm1",
"--cwd",
"/tmp",
"--command",
@@ -142,7 +163,6 @@ mod tests {
"/repos/foo",
"--branch",
"feat/x",
"--cleanup-on-kill",
"-e",
"FOO=bar",
"-e",
@@ -151,10 +171,8 @@ mod tests {
.unwrap();
match cli.command {
Command::Spawn(a) => {
assert_eq!(a.daemon, "vm1");
assert_eq!(a.command, "bash");
assert_eq!(a.branch.as_deref(), Some("feat/x"));
assert!(a.cleanup_on_kill);
assert_eq!(
a.env,
vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),]
@@ -164,13 +182,36 @@ mod tests {
}
}
/// Review fix: without a flag the CLI always sent `create_branch: true`,
/// so the daemon's existing-branch path was unreachable and spawning onto
/// a branch that already existed just failed.
#[test]
fn parses_spawn_onto_an_existing_branch() {
let cli = Cli::try_parse_from([
"maestro",
"spawn",
"--cwd",
"/tmp",
"--command",
"bash",
"--worktree-from",
"/repo",
"--branch",
"feat/existing",
"--existing-branch",
])
.unwrap();
let Command::Spawn(a) = cli.command else {
panic!("expected spawn")
};
assert!(a.existing_branch);
}
#[test]
fn spawn_worktree_requires_branch() {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--daemon",
"vm1",
"--cwd",
"/tmp",
"--command",
@@ -192,13 +233,37 @@ mod tests {
}
}
#[test]
fn parses_kill_with_force() {
let id = "01234567-89ab-cdef-0123-456789abcdef";
let cli = Cli::try_parse_from(["maestro", "kill", "--force", id]).unwrap();
match cli.command {
Command::Kill(a) => {
assert!(a.force);
assert_eq!(a.session_id.to_string(), id);
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn kill_force_defaults_false() {
let id = "01234567-89ab-cdef-0123-456789abcdef";
let cli = Cli::try_parse_from(["maestro", "kill", id]).unwrap();
match cli.command {
Command::Kill(a) => {
assert!(!a.force);
assert_eq!(a.session_id.to_string(), id);
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn rejects_env_without_equals() {
let err = Cli::try_parse_from([
"maestro",
"spawn",
"--daemon",
"x",
"--cwd",
"/",
"--command",
@@ -209,4 +274,56 @@ mod tests {
.unwrap_err();
assert!(err.to_string().contains("KEY=VALUE"));
}
#[test]
fn parses_worktrees_list() {
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
match cli.command {
Command::Worktrees(w) => match w.command {
WorktreesCommand::List(a) => assert!(a.json),
_ => panic!("wrong worktrees subcommand"),
},
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parses_worktrees_reclaim() {
let id = "01234567-89ab-cdef-0123-456789abcdef";
let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap();
match cli.command {
Command::Worktrees(w) => match w.command {
WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id),
_ => panic!("wrong worktrees subcommand"),
},
_ => panic!("wrong subcommand"),
}
}
/// Contract rule: reclaim refuses to run without an explicit target.
/// There is no reclaim-everything convenience.
#[test]
fn reclaim_requires_an_explicit_target() {
let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err();
assert!(
matches!(
err.kind(),
clap::error::ErrorKind::InvalidSubcommand
| clap::error::ErrorKind::MissingRequiredArgument
),
"{err}"
);
}
#[test]
fn worktrees_without_subcommand_shows_help() {
let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err();
assert!(
matches!(
err.kind(),
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
),
"{err}"
);
}
}
-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()
}
);
}
}
+81 -228
View File
@@ -1,41 +1,26 @@
//! 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, WorktreesCommand};
use crate::sessions::{run_kill, run_list, run_spawn};
use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim};
use maestro_config::{default_config_path, MaestroConfigFile};
#[derive(Debug, thiserror::Error)]
#[allow(clippy::result_large_err)]
pub enum CliError {
#[error("config: {0}")]
Config(#[from] 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 +28,105 @@ 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
// #83: warn BEFORE the attempt, so the refusal is not a surprise.
// Best-effort: an unreachable list must not block the kill.
if !args.force {
warn_if_blocked(&daemon, args.session_id).await;
}
let outcome = run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id);
if let Some(w) = outcome.retained {
println!(
"worktree retained at {} (branch {}, force-killed)",
w.path.display(),
w.branch
);
}
}
Command::Worktrees(args) => match args.command {
WorktreesCommand::List(list_args) => {
let mut stdout = std::io::stdout().lock();
run_worktree_list(&daemon, &list_args, &mut stdout).await?;
}
WorktreesCommand::Reclaim(reclaim_args) => {
// The daemon removes the directory and the store entry; the
// branch is never deleted — worth saying out loud (#86).
let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?;
match entry {
Some(wt) => println!(
"reclaimed {} ({})\nbranch '{}' kept",
wt.id,
wt.path.display(),
wt.branch
),
None => println!("reclaimed {}\nbranch kept", reclaim_args.id),
}
}
},
}
}
#[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();
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
const CTRL_Q: u8 = 0x11;
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()),
/// #83: say "this will be refused" before the operator commits to it, in the
/// same words [`maestro_protocol::InTheWay`] uses everywhere else.
async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
let Ok(sessions) = daemon.list_sessions().await else {
return;
};
println!("spawned {}", session.id);
if attach_after {
attach_session(
&home_url,
registry_url_override,
&cfg,
config_path,
session.id,
detach_key,
)
.await?;
}
Ok(())
}
#[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() => {
if let Some(b) = sessions
.iter()
.find(|s| s.id == id)
.and_then(|s| s.blocked_work)
{
if !b.is_clear() {
eprintln!(
"[maestro] home daemon at {home_url} unreachable; \
falling back to the registry (legacy direct attach)"
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it"
);
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).
/// HTTP client pointed at the daemon's API. That surface needs no auth —
/// reaching `bind_addr` is the authorization.
#[allow(clippy::result_large_err)]
fn 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();
}
}
+2 -6
View File
@@ -1,15 +1,11 @@
//! `maestro` CLI — thin client for the registry/daemon API.
//!
//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §"Wire protocol"
//! and the registry's `client_api.rs` for the HTTP surface this client targets.
//! `maestro` CLI — thin client for the local daemon's `/api/*` surface.
#![deny(missing_debug_implementations)]
pub mod attach;
pub mod cli;
pub mod config;
pub mod dispatch;
pub mod http;
pub mod sessions;
pub mod worktrees;
pub use dispatch::{dispatch, CliError};
+133 -156
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());
@@ -51,112 +43,103 @@ pub async fn run_spawn(client: &RegistryClient, args: SpawnArgs) -> Result<Sessi
(Some(repo), Some(branch)) => Some(WorktreeSpec {
base_repo: repo.clone(),
branch: branch.clone(),
cleanup_on_kill: args.cleanup_on_kill,
create_branch: !args.existing_branch,
}),
_ => None,
};
let body = CreateSessionRequest {
daemon_id: args.daemon.clone(),
cwd: args.cwd.clone(),
command: args.command.clone(),
env,
metadata,
worktree,
};
let session: Session = client.post_json("/api/sessions", &body).await?;
Ok(session)
client.create_session(&body).await
}
/// `maestro kill`
pub async fn run_kill(client: &RegistryClient, args: &KillArgs) -> Result<(), HttpError> {
client
.delete(&format!("/api/sessions/{}", args.session_id))
.await
}
/// `GET /api/sessions/:id/connect-info`
pub async fn fetch_connect_info(
client: &RegistryClient,
session_id: Uuid,
) -> Result<ConnectInfo, HttpError> {
client
.get_json(&format!("/api/sessions/{session_id}/connect-info"))
.await
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
/// of removing it; the outcome names what was kept so dispatch can say where
/// the work went.
pub async fn run_kill(
client: &DaemonClient,
args: &KillArgs,
) -> Result<maestro_protocol::KillOutcome, ClientError> {
client.kill_session(args.session_id, args.force).await
}
#[allow(clippy::write_literal)]
fn print_session_table(out: &mut impl Write, sessions: &[Session], daemons: &[DaemonInfo]) {
fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &DaemonInfo) {
if sessions.is_empty() {
if daemons.is_empty() {
writeln!(out, "no sessions, no daemons connected").ok();
} else {
writeln!(
out,
"no sessions ({} daemon{} connected)",
daemons.len(),
if daemons.len() == 1 { "" } else { "s" }
)
.ok();
}
writeln!(out, "no sessions").ok();
return;
}
// Group by daemon, stable order.
let mut by_daemon: Vec<(String, Vec<&Session>)> = Vec::new();
for s in sessions {
if let Some(slot) = by_daemon.iter_mut().find(|(d, _)| d == &s.daemon_id) {
slot.1.push(s);
} else {
by_daemon.push((s.daemon_id.clone(), vec![s]));
}
}
for (_, group) in &mut by_daemon {
group.sort_by_key(|s| s.created_at);
}
let mut sorted: Vec<&Session> = sessions.iter().collect();
sorted.sort_by_key(|s| s.created_at);
for (daemon_id, group) in &by_daemon {
let 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} {:<40} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
)
.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(),
};
// #83: what an unforced kill would be refused for, in InTheWay's own
// words; blank while nothing blocks (or hasn't been probed yet).
let blocked = blocked_label(s);
writeln!(
out,
" {:<36} {:<8} {:<9} {:<28} {}",
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
s.id, status, activity, dir, command
)
.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();
}
}
}
/// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`.
/// Empty unless something actually blocks — "no unsaved work" would just be
/// noise on every row.
pub(crate) fn blocked_label(s: &Session) -> String {
s.blocked_work
.filter(|b| !b.is_clear())
.map(|b| b.to_string())
.unwrap_or_default()
}
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
/// UI's abbreviation so CLI and browser show the same directory shorthand.
fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
let p = path.to_string_lossy();
if !home.is_empty() {
if p == home {
@@ -175,57 +158,50 @@ 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(),
metadata: HashMap::new(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
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 +211,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 +233,42 @@ 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"
);
super::print_session_table(&mut buf, &[], &info(""));
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
}
/// #83: a blocked session shows what an unforced kill would be refused
/// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing.
#[test]
fn 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(),
}];
fn shows_the_blocked_work_summary_only_when_something_blocks() {
use maestro_protocol::InTheWay;
let mut blocked = session("/tmp", "claude", 1);
blocked.blocked_work = Some(InTheWay {
uncommitted_files: 3,
unpushed_commits: 2,
..InTheWay::default()
});
let mut clear = session("/tmp", "bash", 2);
clear.blocked_work = Some(InTheWay::default());
let unprobed = session("/tmp", "zsh", 3);
let mut buf = Vec::new();
super::print_session_table(&mut buf, &[], &daemons);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("1 daemon connected"), "{s}");
super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info(""));
let out = String::from_utf8(buf).unwrap();
assert!(
out.contains("3 uncommitted files, 2 unpushed commits"),
"{out}"
);
// Clear and unprobed rows stay blank in the BLOCKED column.
let lines: Vec<&str> = out.lines().collect();
let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap();
for line in &lines[header + 1..] {
if !line.contains("uncommitted files") {
assert!(!line.contains("unsaved work"), "{line}");
}
}
}
}
+204
View File
@@ -0,0 +1,204 @@
//! `maestro worktrees list / reclaim` — the retained-store surface (#86).
//!
//! `reclaim` removes one directory; it never deletes the branch, which is
//! said out loud in the output because "reclaim" otherwise sounds like it
//! takes the commits too.
use std::io::Write;
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
use crate::sessions::abbreviate_dir;
/// `maestro worktrees list`
pub async fn run_list(
client: &DaemonClient,
args: &ListWorktreesArgs,
out: &mut impl Write,
) -> Result<(), ClientError> {
let worktrees = client.list_worktrees().await?;
let info = client.daemon_info().await.unwrap_or_default();
if args.json {
let body = serde_json::json!({ "worktrees": worktrees });
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
return Ok(());
}
print_worktree_table(out, &worktrees, &info);
Ok(())
}
/// `maestro worktrees reclaim <id>`
///
/// Returns the entry as listed just before the reclaim, when it was found in
/// the listing, so the confirmation can name what was removed.
pub async fn run_reclaim(
client: &DaemonClient,
args: &ReclaimArgs,
) -> Result<Option<RetainedWorktree>, ClientError> {
let listed = client
.list_worktrees()
.await?
.into_iter()
.find(|w| w.id == args.id);
client.reclaim_worktree(args.id).await?;
Ok(listed)
}
fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) {
if worktrees.is_empty() {
writeln!(out, "no retained worktrees").ok();
return;
}
let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect();
sorted.sort_by_key(|w| w.retained_at);
if !info.hostname.is_empty() {
let warn = if maestro_protocol::is_compatible(&info.version) {
""
} else {
" (protocol mismatch)"
};
let version = if info.version.is_empty() {
String::new()
} else {
format!(" maestro {}{warn}", info.version)
};
writeln!(out, "\n{}{version}", info.hostname).ok();
}
writeln!(
out,
" {:<36} {:<24} {:<28} {:<24} IN THE WAY",
"ID", "BRANCH", "PATH", "REASON"
)
.ok();
for w in sorted {
writeln!(
out,
" {:<36} {:<24} {:<28} {:<24} {}",
w.id,
w.branch,
abbreviate_dir(&w.path, &info.home_dir),
reason_label(w.reason),
blocker_label(&w.in_the_way),
)
.ok();
}
// The branch survives every reclaim — see docs/WORKTREES.md.
writeln!(
out,
"\nreclaim removes the directory only; branches are never deleted"
)
.ok();
}
fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str {
match reason {
maestro_protocol::RetainedReason::ForceKilled => "force-killed",
maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
}
}
/// The same breakdown that blocks an unforced kill, as words: what is still
/// in the way, or `clear` when the work has since been committed and pushed.
fn blocker_label(in_the_way: &InTheWay) -> String {
if in_the_way.is_clear() {
return "clear".into();
}
let mut parts: Vec<String> = Vec::new();
if in_the_way.uncommitted_files > 0 {
parts.push(format!("{} uncommitted", in_the_way.uncommitted_files));
}
if in_the_way.untracked_files > 0 {
parts.push(format!("{} untracked", in_the_way.untracked_files));
}
if in_the_way.unpushed_commits > 0 {
parts.push(format!("{} unpushed", in_the_way.unpushed_commits));
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree};
use uuid::Uuid;
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
RetainedWorktree {
id: Uuid::from_u128(id),
path: PathBuf::from(path),
base_repo: PathBuf::from("/home/naps/maestro"),
branch: branch.into(),
reason: RetainedReason::ForceKilled,
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: InTheWay::default(),
}
}
fn info(home: &str) -> DaemonInfo {
DaemonInfo {
hostname: "naps-laptop".into(),
home_dir: home.into(),
..Default::default()
}
}
#[test]
fn formats_table() {
let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1);
w.in_the_way = InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
};
let mut buf = Vec::new();
super::print_worktree_table(&mut buf, &[w], &info("/home/naps"));
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("naps-laptop"), "{s}");
assert!(s.contains("feat/login"), "{s}");
// Home dir collapses to ~ in the PATH column, like sessions' DIR.
assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}");
assert!(s.contains("force-killed"), "{s}");
assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}");
assert!(s.contains("IN THE WAY"), "{s}");
}
/// Contract rule, visible to the operator: reclaiming a tree must not
/// throw away commits on its branch.
#[test]
fn says_the_branch_survives() {
let mut buf = Vec::new();
super::print_worktree_table(
&mut buf,
&[worktree("feat/x", "/repo/worktrees/feat/x", 1)],
&info(""),
);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("never deleted"), "{s}");
}
#[test]
fn clear_tree_says_clear() {
let mut buf = Vec::new();
super::print_worktree_table(
&mut buf,
&[worktree("main-ish", "/repo/worktrees/main-ish", 1)],
&info(""),
);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("clear"), "{s}");
}
#[test]
fn empty_store() {
let mut buf = Vec::new();
super::print_worktree_table(&mut buf, &[], &info(""));
assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n");
}
}
@@ -0,0 +1,110 @@
//! The CLI against a daemon. It had no such tests: `maestro list`'s formatting
//! was unit-tested, but nothing exercised the client, the error decoding, or
//! the unreachable-daemon path without compiling and forking a real daemon.
use maestro_protocol::{DaemonClient, DaemonInfo};
use maestro_testing::{session, MockDaemon};
use uuid::Uuid;
#[tokio::test]
async fn list_reads_the_daemon_catalog() {
let mock = MockDaemon::start().await;
mock.add_session(session(Uuid::from_u128(1), "/home/mock/work", "claude"));
let sessions = mock.client().list_sessions().await.expect("list");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].command, "claude");
}
#[tokio::test]
async fn info_carries_version_and_presets() {
let mock = MockDaemon::start().await;
mock.set_info(DaemonInfo {
hostname: "konishi".into(),
version: "9.9.9".into(),
home_dir: "/home/naps".into(),
presets: vec![maestro_protocol::SpawnPreset {
id: "claude".into(),
label: "Claude".into(),
command: "claude".into(),
}],
});
let info = mock.client().daemon_info().await.expect("info");
// The CLI's own DaemonInfo used to drop both of these fields.
assert_eq!(info.version, "9.9.9");
assert_eq!(info.presets.len(), 1);
}
#[tokio::test]
async fn a_structured_failure_surfaces_the_daemon_message() {
let mock = MockDaemon::start().await;
mock.force_error(502, "tmux_failed", "no server running");
let err = mock
.client()
.list_sessions()
.await
.expect_err("should fail");
assert_eq!(
err.daemon_message(),
Some("no server running"),
"the daemon's own message must survive: {err}"
);
assert!(!err.is_unreachable());
}
#[tokio::test]
async fn an_unreachable_daemon_is_distinguishable_from_a_rejection() {
// Port 1 has nothing listening on it.
let client = DaemonClient::new("http://127.0.0.1:1");
let err = client.list_sessions().await.expect_err("should fail");
assert!(
err.is_unreachable(),
"connection refused must not read as an HTTP rejection: {err}"
);
}
#[tokio::test]
async fn killing_an_unknown_session_reports_the_daemon_reason() {
let mock = MockDaemon::start().await;
let err = mock
.client()
.kill_session(Uuid::new_v4(), false)
.await
.expect_err("should fail");
assert_eq!(err.daemon_message(), Some("no such session on this daemon"));
}
#[tokio::test]
async fn spawn_round_trips_through_the_daemon() {
let mock = MockDaemon::start().await;
let created = mock
.client()
.create_session(&maestro_protocol::CreateSessionRequest {
cwd: "/home/mock".into(),
command: "bash".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
})
.await
.expect("create");
assert_eq!(created.command, "bash");
assert_eq!(mock.session_count(), 1);
mock.client()
.kill_session(created.id, false)
.await
.expect("kill");
assert_eq!(mock.session_count(), 0);
}
+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
+140 -420
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,51 @@ 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")]
/// Override root for ephemeral worktrees the daemon creates on
/// `CreateSession` with a `worktree` spec. Unset by default: worktrees
/// then live inside their base repo at `<repo>/worktrees/<branch>` (#56).
#[serde(default)]
pub worktree_root: Option<PathBuf>,
/// Operator-set perimeter id (e.g. `"wg-naps62"`). When two daemons share
/// a perimeter, peer-to-peer attach can use the T2 transport. Advertised
/// to the registry in heartbeats. See connection-orchestration spec.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perimeter_id: Option<String>,
/// Daemon's reachable address on the declared perimeter (e.g.
/// `"10.0.0.7:7070"`). Advertised in heartbeats; the registry hands it
/// back to peer daemons as a T2 dial target.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub private_addr: Option<String>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
/// receives this much of the prior PTY output before the live stream.
/// `None` ⇒ daemon default (~64 KB); `Some(0)` disables replay.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub pty_replay_bytes: Option<usize>,
/// `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 +103,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 +131,49 @@ 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>>,
/// Root directories the daemon scans for git repositories (#54), `~`
/// expanded by the daemon against its own home. When absent the daemon
/// scans `["~"]`.
#[serde(default)]
pub project_roots: Option<Vec<String>>,
/// How many directory levels below each [`Self::project_roots`] entry the
/// scanner descends (#54). When absent the daemon uses `3`.
#[serde(default)]
pub project_scan_depth: Option<u32>,
}
impl SpawnSection {
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 +195,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 +275,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 +306,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 +328,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 +362,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.
@@ -636,6 +396,7 @@ mod tests {
label: "fish".into(),
command: "fish".into(),
}]),
..Default::default()
};
let ids: Vec<String> = section
.resolve()
@@ -652,6 +413,7 @@ mod tests {
// only the implicit custom tile.
let section = SpawnSection {
presets: Some(vec![]),
..Default::default()
};
let ids: Vec<String> = section
.resolve()
@@ -670,6 +432,7 @@ mod tests {
label: "x".into(),
command: "x".into(),
}]),
..Default::default()
};
assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId);
@@ -679,6 +442,7 @@ mod tests {
label: "x".into(),
command: "".into(),
}]),
..Default::default()
};
assert_eq!(
empty_cmd.resolve().unwrap_err(),
@@ -701,6 +465,7 @@ mod tests {
command: "a2".into(),
},
]),
..Default::default()
};
assert_eq!(
dup.resolve().unwrap_err(),
@@ -713,6 +478,7 @@ mod tests {
label: "Custom".into(),
command: "whatever".into(),
}]),
..Default::default()
};
assert_eq!(
reserved.resolve().unwrap_err(),
@@ -747,127 +513,81 @@ presets = [
}
#[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}");
fn spawn_project_scan_fields_default_to_none() {
// Absent [spawn].project_* → None; the daemon applies its own defaults.
assert_eq!(SpawnSection::default().project_roots, None);
assert_eq!(SpawnSection::default().project_scan_depth, None);
}
#[test]
fn spawn_project_scan_fields_parse_from_toml() {
let cfg = toml::from_str::<MaestroConfigFile>(
r#"
[spawn]
project_roots = ["~/code", "/srv/work"]
project_scan_depth = 5
"#,
)
.expect("parse");
assert_eq!(
cfg.spawn.project_roots,
Some(vec!["~/code".to_string(), "/srv/work".to_string()])
);
assert_eq!(cfg.spawn.project_scan_depth, Some(5));
}
#[test]
fn partial_file_loads_with_section_defaults() {
// Only [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");
}
}
+5 -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,24 +16,23 @@ chrono.workspace = true
clap.workspace = true
dirs.workspace = true
futures-util.workspace = true
jsonwebtoken.workspace = true
libc.workspace = true
maestro-config.workspace = true
maestro-names.workspace = true
maestro-protocol.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
urlencoding = "2"
uuid.workspace = true
[dev-dependencies]
reqwest.workspace = true
tokio-tungstenite.workspace = true
urlencoding = "2"
tempfile.workspace = true
maestro-registry = { path = "../maestro-registry" }
maestro-names.workspace = true
# `test-util` enables `start_paused` for instant timeout tests (NAP-270).
tokio = { workspace = true, features = ["test-util"] }
+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
+167
View File
@@ -0,0 +1,167 @@
//! `GET /api/branches` — local branches of one repository (#55).
//!
//! Spawning into a worktree on an *existing* branch needs to know which
//! branches the repository has and which are taken: git refuses to check the
//! same branch out in two worktrees at once. Each [`BranchInfo`] answers for
//! one local branch — its name, whether it is the main checkout's HEAD, and
//! which linked worktree (if any) holds it.
//!
//! Read-only, like [`crate::gitroot`]: two git probes (`for-each-ref`,
//! `worktree list --porcelain`) with no write path anywhere. A path outside
//! any repository yields an empty list rather than an error, matching how
//! `/api/gitroot` reports "not a repository".
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use axum::extract::{Query, State};
use axum::response::{IntoResponse, Response};
use axum::Json;
use maestro_protocol::BranchInfo;
use serde::Deserialize;
use crate::local_api::LocalApiState;
/// Query string for `GET /api/branches`. Same `path` convention as
/// `/api/dirs` and `/api/gitroot`: resolved against the daemon's home dir.
#[derive(Debug, Deserialize)]
pub struct BranchesQuery {
#[serde(default)]
pub path: String,
}
pub async fn handler(State(s): State<LocalApiState>, Query(q): Query<BranchesQuery>) -> Response {
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
Json(list(&dir)).into_response()
}
/// List the enclosing repository's local branches. Empty when `dir` is not
/// inside a repository.
pub fn list(dir: &Path) -> Vec<BranchInfo> {
let Some(root) = crate::gitroot::find_root(dir) else {
return Vec::new();
};
let worktrees = worktree_branches(&root);
// The first porcelain record is always the main checkout; its branch is
// the HEAD answer. Linked worktrees are the occupancy map.
let head_branch = worktrees.first().and_then(|(branch, _)| branch.clone());
let occupied: HashMap<&str, &Path> = worktrees
.iter()
.skip(1)
.filter_map(|(branch, path)| branch.as_deref().map(|b| (b, path.as_path())))
.collect();
branch_names(&root)
.into_iter()
.map(|name| BranchInfo {
is_head: head_branch.as_deref() == Some(name.as_str()),
worktree: occupied.get(name.as_str()).copied().map(Path::to_path_buf),
name,
})
.collect()
}
fn run_git(root: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn branch_names(root: &Path) -> Vec<String> {
run_git(
root,
&["for-each-ref", "refs/heads", "--format=%(refname:short)"],
)
.map(|out| {
out.lines()
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
fn worktree_branches(root: &Path) -> Vec<(Option<String>, PathBuf)> {
let out = match run_git(root, &["worktree", "list", "--porcelain"]) {
Some(out) => out,
None => return Vec::new(),
};
parse_worktrees(&out)
}
/// Parse `git worktree list --porcelain` into `(branch, toplevel)` records,
/// branch-less for detached/bare checkouts. Records are separated by blank
/// lines; each `worktree` line starts a new one.
fn parse_worktrees(out: &str) -> Vec<(Option<String>, PathBuf)> {
let mut result: Vec<(Option<String>, PathBuf)> = Vec::new();
let mut pending: Option<PathBuf> = None;
let mut branch: Option<String> = None;
for line in out.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if let Some(prev) = pending.take() {
result.push((branch.take(), prev));
}
pending = Some(PathBuf::from(path));
} else if let Some(b) = line.strip_prefix("branch ") {
branch = Some(b.trim_start_matches("refs/heads/").to_owned());
}
}
if let Some(path) = pending {
result.push((branch.take(), path));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_main_and_linked_worktrees() {
let out = "\
worktree /repos/maestro
HEAD 0123abcd
branch refs/heads/main
worktree /repos/wt-feature
HEAD 4567abcd
branch refs/heads/spawn-v2
";
assert_eq!(
parse_worktrees(out),
vec![
(Some("main".into()), PathBuf::from("/repos/maestro")),
(Some("spawn-v2".into()), PathBuf::from("/repos/wt-feature")),
]
);
}
#[test]
fn detached_head_yields_no_branch() {
let out = "\
worktree /repos/detached
HEAD 0123abcd
detached
";
assert_eq!(
parse_worktrees(out),
vec![(None, PathBuf::from("/repos/detached"))]
);
}
#[test]
fn empty_output_yields_no_records() {
assert!(parse_worktrees("").is_empty());
}
}
-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);
}
}
+26 -110
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"
);
@@ -91,7 +47,8 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let _ = default_state_dir();
std::fs::create_dir_all(config.sidecar_dir())?;
std::fs::create_dir_all(config.fifo_dir())?;
std::fs::create_dir_all(&config.worktree_root)?;
// No worktree dir to pre-create: without an override the worktrees live
// under their base repos, created on demand (#56).
let sidecar = SidecarStore::new(config.sidecar_dir());
let worktree_mgr = WorktreeManager::new(config.worktree_root.clone());
@@ -99,9 +56,11 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
Some(p) => Tmux::new().with_tmpdir(p),
None => Tmux::new(),
};
let retained = std::sync::Arc::new(crate::retained_store::RetainedStore::new(
config.retained_worktree_dir(),
));
let manager = SessionManager::new_with_replay(
config.daemon_id.clone(),
tmux,
std::sync::Arc::new(tmux),
sidecar,
worktree_mgr,
config.fifo_dir(),
@@ -110,57 +69,34 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
config.spawn_history_path(),
config.spawn_history_days,
));
))
// One store instance shared with the API handlers: forced kills (#81)
// record here, `GET /api/worktrees` (#80) reads back from here.
.with_retained_store((*retained).clone());
let recovered = manager.recover().await?;
tracing::info!(recovered, "adopted existing tmux sessions");
manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL);
let verifier = JwtVerifier::new(&config.token, &config.daemon_id);
let pair_verifier = PairAttachVerifier::new(&config.token, &config.daemon_id);
let projects = std::sync::Arc::new(crate::projects::ProjectScanner::new(
config.project_roots.clone(),
config.project_scan_depth,
PathBuf::from(&config.home_dir),
));
projects.start_background_refresh();
let 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));
projects,
retained,
));
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 +108,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(())
}
+127 -313
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,16 @@ 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";
/// Default scan roots for project discovery (#54) when `[spawn].project_roots`
/// is unset: the whole home directory, with the scanner's own ignore list
/// keeping it cheap.
pub const DEFAULT_PROJECT_ROOTS: [&str; 1] = ["~"];
/// Default directory depth for project discovery (#54) when
/// `[spawn].project_scan_depth` is unset.
pub const DEFAULT_PROJECT_SCAN_DEPTH: u32 = 3;
/// Env var that overrides `[daemon].bind_addr`.
pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR";
/// Env var that overrides `[daemon].perimeter_id`.
pub const ENV_DAEMON_PERIMETER_ID: &str = "MAESTRO_DAEMON_PERIMETER_ID";
/// Env var that overrides `[daemon].private_addr`.
pub const ENV_DAEMON_PRIVATE_ADDR: &str = "MAESTRO_DAEMON_PRIVATE_ADDR";
/// Env var that overrides `[daemon].tmux_tmpdir`.
pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR";
@@ -57,12 +46,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 +53,18 @@ 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>,
/// `[daemon].worktree_root` override (#56). `None` — the default — places
/// worktrees inside their base repo at `<repo>/worktrees/<branch>`;
/// operators who want them elsewhere set this.
pub worktree_root: Option<PathBuf>,
/// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach
/// receives this much of the prior PTY output before the live stream.
/// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables.
@@ -104,9 +79,15 @@ 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>,
/// Project-discovery scan roots (#54): `[spawn].project_roots` (default
/// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home.
pub project_roots: Vec<PathBuf>,
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
pub project_scan_depth: u32,
}
impl DaemonConfig {
@@ -123,28 +104,9 @@ impl DaemonConfig {
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
}
}
/// Retained-worktree store directory (#78): `<state_dir>/retained-worktrees/`.
pub fn retained_worktree_dir(&self) -> PathBuf {
self.state_dir.join("retained-worktrees")
}
}
@@ -152,13 +114,7 @@ impl DaemonConfig {
/// `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 +122,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 +143,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)?,
@@ -246,12 +153,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
Some(p) => p,
None => default_state_dir()?,
};
let worktree_root = daemon
.worktree_root
.unwrap_or_else(|| state_dir.join("worktrees"));
let perimeter_id = env.perimeter_id.or(daemon.perimeter_id);
let private_addr = env.private_addr.or(daemon.private_addr);
let pty_replay_bytes = daemon
.pty_replay_bytes
.unwrap_or(crate::pty::DEFAULT_REPLAY_BYTES);
@@ -264,27 +166,51 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
.spawn_history_days
.unwrap_or(DEFAULT_SPAWN_HISTORY_DAYS);
// `~` in scan roots means the daemon's own home; an unresolvable home
// leaves the tilde as-is and the scanner treats the root as missing.
let project_roots: Vec<PathBuf> = file
.spawn
.project_roots
.unwrap_or_else(|| {
DEFAULT_PROJECT_ROOTS
.iter()
.map(|s| s.to_string())
.collect()
})
.iter()
.map(|root| expand_tilde(root, &home_dir))
.collect();
let project_scan_depth = file
.spawn
.project_scan_depth
.unwrap_or(DEFAULT_PROJECT_SCAN_DEPTH);
Ok(DaemonConfig {
daemon_id,
hostname,
home_dir,
registry_url,
bind_addr,
daemon_url,
token,
state_dir,
worktree_root,
perimeter_id,
private_addr,
worktree_root: daemon.worktree_root,
pty_replay_bytes,
tmux_tmpdir,
spawn_history_days,
spawn_presets,
project_roots,
project_scan_depth,
})
}
fn env_has_all_required(env: &EnvOverrides) -> bool {
env.registry_url.is_some() && env.daemon_url.is_some() && env.token.is_some()
/// Expand a leading `~` (or `~/…`) against `home`. Other paths pass through.
fn expand_tilde(path: &str, home: &str) -> PathBuf {
match path.strip_prefix("~") {
Some(_) if home.is_empty() => PathBuf::from(path),
Some(rest) => {
let mut p = PathBuf::from(home);
p.push(rest.trim_start_matches('/'));
p
}
None => PathBuf::from(path),
}
}
pub fn default_state_dir() -> Result<PathBuf, ConfigError> {
@@ -307,60 +233,27 @@ pub fn system_hostname() -> String {
mod tests {
use super::*;
fn minimal_file() -> &'static str {
r#"
[shared]
registry_url = "https://r.example"
token = "abc"
[daemon]
daemon_url = "ws://localhost:7070"
"#
}
#[test]
fn minimal_file_defaults_the_rest() {
fn empty_file_defaults_everything() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, minimal_file()).unwrap();
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.registry_url, "wss://r.example/daemon");
assert_eq!(cfg.daemon_url, "ws://localhost:7070");
assert_eq!(cfg.token, "abc");
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(cfg.state_dir.ends_with("maestro"));
assert!(cfg.worktree_root.ends_with("worktrees"));
assert_eq!(cfg.daemon_id, cfg.hostname);
assert!(cfg.worktree_root.is_none());
assert!(!cfg.hostname.is_empty());
}
#[test]
fn missing_file_with_full_env_works() {
// systemd / container scenario: no config file, everything in env.
fn missing_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
let env = EnvOverrides {
registry_url: Some("http://r.example".into()),
daemon_url: Some("ws://x:7070".into()),
token: Some("t".into()),
..EnvOverrides::default()
};
let cfg = load_config(&path, env).unwrap();
assert_eq!(cfg.registry_url, "ws://r.example/daemon");
assert_eq!(cfg.token, "t");
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.bind_addr, default_bind_addr());
assert!(!path.exists(), "load_config must not create the file");
}
#[test]
fn missing_file_without_full_env_errors_with_hard_cutover_message() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
let err = load_config(&path, EnvOverrides::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("maestrod configure"), "msg: {msg}");
assert!(msg.contains("Legacy"), "msg: {msg}");
}
#[test]
fn env_overrides_file() {
let dir = tempfile::tempdir().unwrap();
@@ -368,68 +261,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 +296,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 +324,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
"#,
)
@@ -559,6 +333,56 @@ spawn_history_days = 0
assert_eq!(cfg.spawn_history_days, 0);
}
#[test]
fn project_scan_defaults_to_home_root_at_depth_3() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.project_scan_depth, DEFAULT_PROJECT_SCAN_DEPTH);
assert_eq!(
cfg.project_roots,
vec![PathBuf::from(cfg.home_dir.clone())],
"bare ~ must expand to the daemon's home"
);
}
#[test]
fn project_scan_roots_expand_tilde_and_pass_through_absolutes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
[spawn]
project_roots = ["~/code", "/srv/work"]
project_scan_depth = 5
"#,
)
.unwrap();
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
assert_eq!(cfg.project_scan_depth, 5);
assert_eq!(
cfg.project_roots,
vec![
PathBuf::from(cfg.home_dir.clone()).join("code"),
PathBuf::from("/srv/work"),
]
);
}
#[test]
fn tilde_expansion_handles_bare_and_nested_forms() {
assert_eq!(expand_tilde("~", "/home/u"), PathBuf::from("/home/u"));
assert_eq!(
expand_tilde("~/a/b", "/home/u"),
PathBuf::from("/home/u/a/b")
);
assert_eq!(expand_tilde("/abs", "/home/u"), PathBuf::from("/abs"));
// Unresolvable home leaves the tilde as-is rather than mangling it.
assert_eq!(expand_tilde("~", ""), PathBuf::from("~"));
}
#[test]
fn system_hostname_is_nonempty() {
let h = system_hostname();
@@ -576,12 +400,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 +420,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 +433,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"));
}
}
+2 -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};
@@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
ErrorInfo {
code: code.into(),
message: format!("{message}: {}", dir.display()),
unsaved_work: None,
}
}
+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());
}
}
+11 -17
View File
@@ -1,37 +1,31 @@
//! `maestrod` — the per-host Maestro daemon.
//! `maestro daemon` — the per-host Maestro daemon.
//!
//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §Daemon for the
//! component's responsibilities. This library exposes the pieces; the binary
//! entry point in `src/main.rs` wires them together.
//! Wraps tmux, owns every session on the machine, and serves them to clients
//! (CLI, TUI, web) over a plain HTTP + WebSocket API on a local bind address.
//! No registry, no peers, no auth layer: reaching the bind address is the
//! authorization.
#![deny(missing_debug_implementations)]
pub mod activity;
pub mod attach;
pub mod broker;
pub mod catalog_mirror;
pub mod branches;
pub mod cli;
pub mod config;
pub mod configure;
pub mod dirlist;
pub mod gitroot;
pub mod jwt;
pub mod local_api;
pub mod local_events;
pub mod panes;
pub mod proc;
pub mod projects;
pub mod pty;
pub mod registry_client;
pub mod retained_store;
pub mod session;
pub mod sidecar;
pub mod spawn_history;
pub mod tmux;
pub mod worktree;
pub mod worktree_probe;
pub use broker::{BrokerError, BrokerHandle};
pub use catalog_mirror::CatalogMirror;
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
pub use configure::{run as run_configure, ConfigureError, ConfigureInputs};
pub use jwt::{issue as issue_jwt, JwtError, JwtVerifier, PairAttachVerifier};
pub use local_events::{spawn_session_publisher, LocalEventBus};
pub use registry_client::{ClientError, RegistryClient, HEARTBEAT_INTERVAL};
pub use session::{SessionError, SessionEvent, SessionManager};
pub use session::{SessionError, SessionManager};
+267 -427
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,67 @@ 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, NameBatch, 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,
/// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>,
/// Retained worktrees (#78) backing `GET /api/worktrees` and their
/// reclaim endpoint (#80).
pub retained: Arc<crate::retained_store::RetainedStore>,
}
pub fn router(state: LocalApiState) -> Router {
Router::new()
.route("/api/sessions", get(list_sessions).post(create_session))
.route("/api/sessions/:id", delete(kill_session))
.route("/api/daemons", get(list_daemons))
.route("/api/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::PROJECTS, get(projects))
.route(routes::GITROOT, get(git_root))
.route(routes::BRANCHES, get(crate::branches::handler))
.route(routes::NAMES, get(names))
.route(routes::WORKTREES, get(list_worktrees))
.route(routes::patterns::WORKTREE, delete(reclaim_worktree))
.route(routes::INFO, get(daemon_info))
.route(routes::EVENTS, any(events_ws))
.route(routes::patterns::ATTACH, any(attach_ws))
.with_state(state)
}
async fn list_sessions(State(s): State<LocalApiState>) -> Json<Vec<Session>> {
let mut out = s.manager.snapshot().await;
out.extend(s.catalog.sessions().await);
Json(out)
Json(s.manager.snapshot().await)
}
async fn list_daemons(State(s): State<LocalApiState>) -> Json<Vec<DaemonInfo>> {
let self_info = DaemonInfo {
daemon_id: s.manager.daemon_id().to_string(),
hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(),
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 +93,66 @@ 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,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
// The retention record failed before anything was torn down (#81).
"retain_failed" => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::BAD_GATEWAY,
}
}
/// 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(),
/// Discovered git repositories and worktrees, newest activity first (#54).
/// `last_activity` is the later of the repo's git HEAD mtime and the newest
/// spawn-history use of any directory inside it.
async fn projects(State(s): State<LocalApiState>) -> Response {
let repos = s.projects.repos().await;
let history = s.manager.all_recent_dirs();
Json(crate::projects::to_projects(&repos, &history)).into_response()
}
/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo`
/// for the spawn form (#26). `path` is resolved against the daemon's home dir
/// (empty/`~`/relative), like [`list_dir`].
async fn git_root(State(s): State<LocalApiState>, Query(q): Query<DirQuery>) -> Response {
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
Json(GitRoot {
root: crate::gitroot::find_root(&dir),
})
.into_response()
}
/// Query string for `GET /api/names`. `count` defaults to 1 and is capped at
/// [`maestro_names::MAX_BATCH`] (#53).
#[derive(Debug, Deserialize)]
struct NamesQuery {
count: Option<usize>,
}
/// Fresh session handles for the spawn form's instant reroll (#53). The web
/// client fetches one batch per draft pane and rerolls locally from it.
async fn names(Query(q): Query<NamesQuery>) -> Json<NameBatch> {
let count = q.count.unwrap_or(1).clamp(1, maestro_names::MAX_BATCH);
Json(NameBatch {
names: maestro_names::generate_batch(count),
})
}
async fn daemon_info(State(s): State<LocalApiState>) -> Json<DaemonInfo> {
Json(DaemonInfo {
hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(),
home_dir: (*s.home_dir).clone(),
presets: (*s.presets).clone(),
})
}
@@ -201,20 +161,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,106 +174,206 @@ async fn create_session(
}
}
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
if !s.manager.has(id).await {
// 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(),
/// `kill` already reports a missing session as `SessionError::NotFound`, which
/// maps to 404. Checking first would only add a window in which the liveness
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
///
/// The full error info is passed through rather than rebuilt from
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
///
/// With `force=true` (#81) the refusal is overridden and the worktree
/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept.
/// Without it the historical `204` stands.
#[derive(Debug, Deserialize)]
struct KillQuery {
#[serde(default)]
force: bool,
}
async fn kill_session(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Query(q): Query<KillQuery>,
) -> Response {
match s.manager.kill(id, q.force).await {
Ok(outcome) => {
if q.force {
(StatusCode::OK, Json(outcome)).into_response()
} else {
StatusCode::NO_CONTENT.into_response()
}
}
Err(e) => {
let info = e.to_error_info();
error_response(
StatusCode::BAD_GATEWAY,
info.code.as_str(),
info.message.as_str(),
)
let status = session_error_status(&info.code);
(status, Json(ApiError { error: info })).into_response()
}
}
}
/// 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,
},
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
/// breakdown recomputed at read time — never stored, so a tree whose work has
/// since been committed or pushed says so. Entries whose directory somebody
/// removed by hand are pruned on the way through.
async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
let retained = s.retained.clone();
let listed = tokio::task::spawn_blocking(move || {
retained.prune_missing()?;
retained
.load_all()?
.iter()
.map(|entry| {
// A tree git cannot read (permissions, corrupt checkout)
// still gets listed; it just reports nothing in the way.
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
Ok(maestro_protocol::RetainedWorktree {
id: entry.id,
path: entry.path.clone(),
base_repo: entry.base_repo.clone(),
branch: entry.branch.clone(),
reason: entry.reason,
retained_at: entry.retained_at,
in_the_way,
})
})
.collect::<Result<Vec<_>, crate::retained_store::RetainedStoreError>>()
})
.await;
match listed {
Ok(Ok(worktrees)) => Json(worktrees).into_response(),
Ok(Err(e)) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"store_failed",
&e.to_string(),
),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"join_failed",
&e.to_string(),
),
}
}
/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes
/// the directory and drops the store entry, without consulting the guard —
/// reclaim is the deliberate destructive act, chosen while looking at what is
/// in the way. The branch itself is never deleted; commits on it survive.
async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
let found = {
let retained = s.retained.clone();
tokio::task::spawn_blocking(move || -> Result<
Option<crate::retained_store::RetainedWorktreeRecord>,
crate::retained_store::RetainedStoreError,
> {
Ok(retained.load_all()?.into_iter().find(|e| e.id == id))
})
.await
};
error_response(status, code, &message)
let entry = match found {
Ok(Ok(Some(entry))) => entry,
Ok(Ok(None)) => {
return error_response(
StatusCode::NOT_FOUND,
"not_found",
"no such retained worktree",
)
}
Ok(Err(e)) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"store_failed",
&e.to_string(),
)
}
Err(e) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"join_failed",
&e.to_string(),
)
}
};
let outcome = {
let retained = s.retained.clone();
tokio::task::spawn_blocking(move || -> Result<(), String> {
crate::worktree_probe::reclaim(&entry.base_repo, &entry.path)
.map_err(|e| e.to_string())?;
retained.remove(id).map_err(|e| e.to_string())?;
Ok(())
})
.await
};
match outcome {
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
// The directory could not be removed: leave the entry so the
// operator still sees the tree and can retry.
Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"join_failed",
&e.to_string(),
),
}
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(
status,
Json(serde_json::json!({
"error": ErrorInfo {
code: code.into(),
message: message.into(),
},
})),
)
.into_response()
(status, Json(ApiError::new(code, message))).into_response()
}
/// Convenience constructor — pulls the bits `LocalApiState` needs from
/// `DaemonConfig` so callers don't have to assemble it by hand.
#[allow(clippy::too_many_arguments)]
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
/// scanner starts with its cache cold; wire
/// [`ProjectScanner::start_background_refresh`](crate::projects::ProjectScanner::start_background_refresh)
/// on the returned handle's scanner to keep it eventually fresh.
pub fn state_for(
manager: SessionManager,
hostname: impl Into<String>,
daemon_url: impl Into<String>,
home_dir: impl Into<String>,
presets: Vec<SpawnPreset>,
catalog: CatalogMirror,
events: LocalEventBus,
broker: BrokerHandle,
projects: Arc<crate::projects::ProjectScanner>,
retained: Arc<crate::retained_store::RetainedStore>,
) -> LocalApiState {
LocalApiState {
manager,
hostname: Arc::new(hostname.into()),
daemon_url: Arc::new(daemon_url.into()),
home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets),
catalog,
events,
broker,
projects,
retained,
}
}
// ---------------------------------------------------------------------------
// WS /api/events — live catalog deltas for browser/CLI subscribers (NAP-248)
// WS /api/attach/:id — raw PTY bytes for one session
// ---------------------------------------------------------------------------
#[allow(clippy::result_large_err)]
/// Subscribing *before* the upgrade makes the 404 and the subscription one
/// atomic step. Checking existence and then subscribing inside the callback
/// left a window in which the liveness watcher could reap the session, so a
/// client that passed the check still got a socket that immediately closed.
async fn attach_ws(
Path(id): Path<Uuid>,
State(s): State<LocalApiState>,
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon",
));
};
let manager = s.manager.clone();
Ok(ws.on_upgrade(move |socket| async move {
let _ = crate::attach::serve(socket, manager, id, replay, rx).await;
}))
}
// ---------------------------------------------------------------------------
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
// ---------------------------------------------------------------------------
async fn events_ws(
@@ -339,7 +391,6 @@ async fn pump_events(
) -> Result<(), axum::Error> {
use axum::extract::ws::Message;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::RegistryEvent;
use tokio::sync::broadcast::error::RecvError;
let (mut sink, mut stream) = socket.split();
@@ -347,20 +398,11 @@ async fn pump_events(
// Subscribe BEFORE building the snapshot so deltas applied while we're
// marshaling can't slip between the two and disappear. The UI is
// idempotent for repeated *Added events, so any overlap is harmless.
let mut rx = s.events.subscribe();
let mut rx = s.manager.events();
let self_info = DaemonInfo {
daemon_id: s.manager.daemon_id().to_string(),
hostname: (*s.hostname).clone(),
version: PROTOCOL_VERSION.into(),
daemon_url: (*s.daemon_url).clone(),
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 +413,7 @@ async fn pump_events(
ev = rx.recv() => {
let event = match ev {
Ok(e) => e,
Err(RecvError::Lagged(n)) => RegistryEvent::Lagged { skipped: n },
Err(RecvError::Lagged(n)) => CatalogEvent::Lagged { skipped: n },
Err(RecvError::Closed) => break,
};
let text = serde_json::to_string(&event).expect("event encode");
@@ -393,205 +435,3 @@ async fn pump_events(
}
Ok(())
}
// ---------------------------------------------------------------------------
// Broker WS — `WS /api/attach/:session_id`
// ---------------------------------------------------------------------------
//
// Home-daemon broker (NAP-243 + NAP-247):
//
// 1. Session owned by self → delegate to local PTY subscribe.
// 2. Session owned by a peer daemon → ResolveAttach via the registry, dial
// the peer over T2, proxy frames.
// 3. Unknown session → 404.
use crate::broker::{BrokerError, BrokerHandle};
#[derive(Debug, Clone)]
pub struct BrokerState {
pub manager: SessionManager,
pub catalog: CatalogMirror,
pub broker: BrokerHandle,
pub self_daemon_id: Arc<String>,
pub self_perimeter_id: Option<Arc<String>>,
}
pub fn broker_router(state: BrokerState) -> Router {
Router::new()
.route("/api/attach/:id", any(broker_attach_ws))
.with_state(state)
}
async fn broker_attach_ws(
Path(id): Path<Uuid>,
State(state): State<BrokerState>,
ws: axum::extract::WebSocketUpgrade,
) -> Result<Response, Response> {
if state.manager.has(id).await {
// Local short-circuit.
let manager = state.manager.clone();
return Ok(ws.on_upgrade(move |socket| async move {
let _ = crate::attach::serve_local(socket, manager, id).await;
}));
}
// Look up which peer owns the session via the catalog mirror.
let peer = state
.catalog
.sessions()
.await
.into_iter()
.find(|s| s.id == id)
.map(|s| s.daemon_id);
let Some(peer_daemon_id) = peer else {
return Err(error_response(
StatusCode::NOT_FOUND,
"unknown_session",
"no such session on this daemon or any known peer",
));
};
// ResolveAttach via the control link.
let candidates = match state.broker.resolve_attach(id).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(%id, %peer_daemon_id, error = %e, "ResolveAttach failed");
let (code, msg) = broker_error_code(&e);
return Err(error_response(StatusCode::BAD_GATEWAY, code, &msg));
}
};
// Pick a transport. T1 is canceled (NAP-235); T3 is gated (NAP-238).
// For T2 we need both daemons to declare the same perimeter id, and the
// candidate must include a private address.
let perimeter = candidates.perimeter.clone();
let our_perimeter = state.self_perimeter_id.as_deref().map(|s| s.as_str());
let dial_url = match (perimeter, our_perimeter) {
(Some(p), Some(ours)) if p.perimeter_id == ours => {
format!(
"ws://{}/sessions/{}/stream?token={}",
p.private_addr,
id,
urlencoding::encode(&candidates.jwt),
)
}
(Some(_), Some(_)) => {
return Err(error_response(
StatusCode::BAD_GATEWAY,
"perimeter_mismatch",
"peer is on a different perimeter; T2 unavailable, T1/T3 not implemented",
));
}
(Some(_), None) => {
return Err(error_response(
StatusCode::BAD_GATEWAY,
"no_local_perimeter",
"this daemon has no perimeter_id set; can't use T2",
));
}
(None, _) => {
return Err(error_response(
StatusCode::BAD_GATEWAY,
"no_perimeter_candidate",
"peer hasn't advertised a perimeter; T2 unavailable",
));
}
};
// 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()
));
}
}
+574
View File
@@ -0,0 +1,574 @@
//! Project discovery for `GET /api/projects` (#54).
//!
//! Scans `[spawn].project_roots` (default the whole home) for `.git` entries —
//! directory or file, so linked worktrees count too — up to
//! `[spawn].project_scan_depth` levels. Descent stops at the first repo found:
//! nested independent repos are not discovered, but worktrees of every hit
//! still are, via `git worktree list`, which also catches linked worktrees
//! living outside the roots.
//!
//! Hidden directories are skipped everywhere; heavy well-known directories
//! (`node_modules`, `Downloads`, …) are skipped when they sit directly in the
//! home directory, so scanning all of home stays cheap.
//!
//! Results are cached behind a short TTL and refreshed on a slow background
//! timer ([`ProjectScanner::start_background_refresh`]) — repositories appear
//! rarely, and `/api/projects` may be polled.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use crate::spawn_history::RecentDir;
/// Directory names never descended when they sit directly in the home dir, so
/// scanning all of home stays cheap (#54). Hidden dirs are skipped everywhere
/// regardless.
pub const DEFAULT_HOME_IGNORE: &[&str] =
&["node_modules", "Downloads", "Library", "go", "snap", "opt"];
/// How long a discovery snapshot is served as-is before the next request
/// triggers a rescan.
///
/// A scan walks the roots and runs one `git worktree list` per hit, holding
/// the scanner lock throughout, and clients re-request whenever a draft pane
/// re-mounts — which `multiview.render()` does on unrelated events. Keep this
/// comfortably longer than [`REFRESH_INTERVAL`] so the background timer is
/// what refreshes, and a request only rescans when that timer has failed to.
pub const CACHE_TTL: Duration = Duration::from_secs(180);
/// Background refresh cadence. Repositories appear rarely; this keeps the
/// served list eventually fresh without anyone asking.
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
/// One repository or linked worktree found by [`discover`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredRepo {
pub path: PathBuf,
/// Basename of `path`.
pub name: String,
pub is_worktree: bool,
/// The main checkout this worktree belongs to; set only when
/// [`Self::is_worktree`].
pub base_repo: Option<PathBuf>,
/// Mtime of the checkout's git HEAD — "someone committed / checked out
/// here". The later-of merge with spawn history happens in
/// [`to_projects`], which also sorts.
pub head_mtime: Option<DateTime<Utc>>,
}
/// Repo toplevels under each root of `roots`, at most `max_depth` levels deep.
/// A dir holding `.git` (dir *or* file) is a hit and is not descended into;
/// hidden directories are never entered, and `DEFAULT_HOME_IGNORE` names are
/// skipped directly under `home`.
pub fn scan_roots(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
for root in roots {
walk(root, 0, max_depth, home, &mut found);
}
found.sort();
found.dedup();
found
}
fn walk(dir: &Path, depth: u32, max_depth: u32, home: &Path, found: &mut Vec<PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
let mut subdirs = Vec::new();
let mut has_git = false;
for entry in entries.flatten() {
match entry.file_name().to_string_lossy().into_owned() {
name if name == ".git" => has_git = true,
name if name.starts_with('.') => {}
name if dir == home && DEFAULT_HOME_IGNORE.contains(&name.as_str()) => {}
_ => {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
subdirs.push(entry.path());
}
}
}
}
if has_git {
// Match `git worktree list` output, which prints real paths.
found.push(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
return;
}
if depth >= max_depth {
return;
}
for sub in subdirs {
walk(&sub, depth + 1, max_depth, home, found);
}
}
/// Every discovered project: the scanned repos plus all worktrees their
/// `git worktree list` reports (linked worktrees can live outside the roots).
/// A hit whose git invocation fails still lists, un-worktreed.
pub fn discover(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec<DiscoveredRepo> {
let scanned = scan_roots(roots, max_depth, home);
let mut map: Vec<(PathBuf, PathBuf)> = Vec::new(); // (checkout, main)
for repo in scanned {
if map.iter().any(|(c, _)| c == &repo) {
continue;
}
let worktrees = list_worktrees(&repo).unwrap_or_else(|| vec![repo.clone()]);
let main = worktrees[0].clone();
for wt in worktrees {
if !map.iter().any(|(c, _)| c == &wt) {
map.push((wt, main.clone()));
}
}
}
map.into_iter()
.map(|(path, main)| {
let is_worktree = path != main;
DiscoveredRepo {
name: path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
is_worktree,
base_repo: is_worktree.then_some(main),
head_mtime: head_mtime(&path),
path,
}
})
.collect()
}
/// `git worktree list --porcelain` from `repo`: absolute paths, main first.
/// `None` when git fails (broken/bare checkout).
fn list_worktrees(repo: &Path) -> Option<Vec<PathBuf>> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["worktree", "list", "--porcelain"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let mut paths = Vec::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Some(p) = line.strip_prefix("worktree ") {
paths.push(PathBuf::from(p));
}
}
(!paths.is_empty()).then_some(paths)
}
/// Mtime of the checkout's reflog, falling back to `HEAD` itself.
///
/// `HEAD` is only rewritten on checkout — committing updates
/// `refs/heads/<branch>` and appends to `logs/HEAD`, leaving `HEAD` alone. A
/// repo worked on daily from one long-lived branch would otherwise report its
/// last *branch switch* as its activity and get dimmed as stale (#54 review).
///
/// For a linked worktree `.git` is a file holding a `gitdir:` pointer;
/// resolve it so worktrees report their own reflog rather than nothing.
fn head_mtime(repo: &Path) -> Option<DateTime<Utc>> {
let dot_git = repo.join(".git");
let head = if dot_git.is_dir() {
dot_git.join("HEAD")
} else {
let ptr = std::fs::read_to_string(&dot_git).ok()?;
let gitdir = ptr.trim().strip_prefix("gitdir:")?.trim();
// Relative pointers are relative to the worktree.
let p = Path::new(gitdir);
let p = if p.is_absolute() {
p.to_path_buf()
} else {
repo.join(p)
};
p.join("HEAD")
};
// Prefer the reflog next to whichever HEAD we resolved.
let reflog = head.with_file_name("logs").join("HEAD");
let head = if reflog.exists() { reflog } else { head };
let mtime = std::fs::metadata(head).ok()?.modified().ok()?;
Some(DateTime::<Utc>::from(mtime))
}
/// Fold spawn history into the discovery snapshot and produce the wire list,
/// sorted newest-activity-first. `history` must be newest-first (what
/// [`SpawnHistory::all_recent_dirs`](crate::spawn_history::SpawnHistory)
/// returns): the first entry whose cwd sits inside a project is its most
/// recent use.
pub fn to_projects(
repos: &[DiscoveredRepo],
history: &[RecentDir],
) -> Vec<maestro_protocol::Project> {
let mut projects: Vec<maestro_protocol::Project> = repos
.iter()
.map(|repo| {
let last_use = history.iter().find(|h| contains(&repo.path, &h.cwd));
maestro_protocol::Project {
path: repo.path.clone(),
name: repo.name.clone(),
is_worktree: repo.is_worktree,
base_repo: repo.base_repo.clone(),
last_activity: max_opt(repo.head_mtime, last_use.map(|u| u.last_used)),
last_command: last_use.map(|u| u.last_command.clone()),
}
})
.collect();
projects.sort_by_key(|p| std::cmp::Reverse(p.last_activity));
projects
}
/// Whether `dir` is `project` itself or anywhere inside it.
fn contains(project: &Path, dir: &Path) -> bool {
dir == project || dir.starts_with(project)
}
fn max_opt(a: Option<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Option<DateTime<Utc>> {
match (a, b) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
}
}
/// Cached discovery behind a short TTL plus an optional slow background
/// refresh. Clone-free shared handle via `Arc` in [`crate::local_api`].
#[derive(Debug)]
pub struct ProjectScanner {
roots: Vec<PathBuf>,
max_depth: u32,
home: PathBuf,
ttl: Duration,
cache: tokio::sync::Mutex<Option<(Instant, std::sync::Arc<Vec<DiscoveredRepo>>)>>,
}
impl ProjectScanner {
/// Scanner over the resolved config roots. An empty `roots` list scans
/// nothing and always serves an empty list.
pub fn new(roots: Vec<PathBuf>, max_depth: u32, home: PathBuf) -> Self {
Self {
roots,
max_depth,
home,
ttl: CACHE_TTL,
cache: tokio::sync::Mutex::new(None),
}
}
/// Scanner that never discovers anything — tests and embedders that don't
/// want the endpoint populated.
pub fn disabled() -> Self {
Self::new(Vec::new(), 0, PathBuf::new())
}
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
/// Also the background timer's entry point: a tick inside the TTL is a
/// no-op (#75).
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
let mut cache = self.cache.lock().await;
if let Some((at, repos)) = cache.as_ref() {
if at.elapsed() < self.ttl {
return repos.clone();
}
}
let roots = self.roots.clone();
let max_depth = self.max_depth;
let home = self.home.clone();
// Blocking fs walk + git subprocesses must not stall the reactor.
let repos = std::sync::Arc::new(
tokio::task::spawn_blocking(move || discover(&roots, max_depth, &home))
.await
.unwrap_or_default(),
);
*cache = Some((Instant::now(), repos.clone()));
repos
}
/// One background-refresh tick: rescan only if the cached snapshot has
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
/// roots every [`REFRESH_INTERVAL`].
async fn refresh(&self) {
self.repos().await;
}
/// Spawn the slow background refresh task. Drop the returned handle to
/// detach it; it stops when the runtime shuts down.
pub fn start_background_refresh(self: &std::sync::Arc<Self>) -> tokio::task::JoinHandle<()> {
let scanner = std::sync::Arc::clone(self);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(REFRESH_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await; // the immediate first tick — wait a full interval
loop {
scanner.refresh().await;
ticker.tick().await;
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spawn_history::RecentDir;
/// Real git repo at `<root>/<name>`, one initial commit so HEAD exists.
fn make_repo(root: &Path, name: &str) -> PathBuf {
let path = root.join(name);
std::fs::create_dir_all(&path).unwrap();
run_git(&path, &["init", "-q"]);
std::fs::write(path.join("f.txt"), "x\n").unwrap();
run_git(&path, &["add", "-A"]);
run_git(
&path,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"init",
],
);
path
}
fn run_git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {} | {}",
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&out.stdout)
);
}
fn names(repos: &[DiscoveredRepo]) -> Vec<String> {
let mut v: Vec<String> = repos.iter().map(|r| r.name.clone()).collect();
v.sort();
v
}
#[test]
fn scan_finds_repos_and_stops_at_the_first_hit() {
let tmp = tempfile::tempdir().unwrap();
let outer = make_repo(tmp.path(), "outer");
// A nested independent repo inside `outer` is NOT discovered
// separately: descent stops once a repo is found.
make_repo(&outer, "vendored");
std::fs::create_dir_all(tmp.path().join("plain/deep/deeper")).unwrap();
let found = scan_roots(
&[tmp.path().to_path_buf()],
3,
Path::new("/nonexistent-home"),
);
assert_eq!(found, vec![outer]);
let discovered = discover(
&[tmp.path().to_path_buf()],
3,
Path::new("/nonexistent-home"),
);
assert_eq!(names(&discovered), ["outer"]);
assert!(!discovered[0].is_worktree);
assert_eq!(discovered[0].base_repo, None);
assert!(discovered[0].head_mtime.is_some());
}
#[test]
fn scan_respects_depth_hidden_dirs_and_home_ignore() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
// Hidden dirs are never entered, at any depth.
std::fs::create_dir_all(home.join(".secret/.git")).unwrap();
// node_modules directly under home is not descended…
let _ignored = make_repo(&home.join("node_modules"), "dep");
// …but the same name deeper down is fair game.
let kept = make_repo(&home.join("code/node_modules"), "dep2");
// Depth cap: a repo three levels below the root is the last tier —
// found at depth 3, but a fourth-level repo isn't.
let at_three = home.join("a/b/c");
std::fs::create_dir_all(at_three.join(".git")).unwrap();
std::fs::create_dir_all(home.join("a/b/c/d/.git")).unwrap();
let found = scan_roots(std::slice::from_ref(&home), 3, &home);
assert!(found.iter().all(|p| !p.starts_with(home.join(".secret"))));
assert!(found
.iter()
.all(|p| !p.starts_with(home.join("node_modules"))));
assert!(found.contains(&kept.canonicalize().unwrap()));
assert!(found.contains(&at_three.canonicalize().unwrap()));
assert!(!found.iter().any(|p| p.ends_with("d")));
}
#[test]
fn worktrees_outside_the_roots_are_discovered_via_git_worktree_list() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("scan-root");
std::fs::create_dir_all(&root).unwrap();
let main = make_repo(&root, "main-repo");
// Linked worktree deliberately outside the scan root.
let wt = tmp.path().join("elsewhere/wt-of-main");
std::fs::create_dir_all(wt.parent().unwrap()).unwrap();
run_git(
&main,
&[
"worktree",
"add",
"-q",
"-b",
"feature",
&wt.to_string_lossy(),
],
);
let discovered = discover(&[root], 3, Path::new("/nonexistent-home"));
assert_eq!(names(&discovered), ["main-repo", "wt-of-main"]);
let wt_entry = discovered.iter().find(|d| d.name == "wt-of-main").unwrap();
assert!(wt_entry.is_worktree);
assert_eq!(
wt_entry.base_repo.as_deref(),
Some(main.canonicalize().unwrap().as_path())
);
let main_entry = discovered.iter().find(|d| d.name == "main-repo").unwrap();
assert!(!main_entry.is_worktree);
}
#[test]
fn head_mtime_resolves_a_worktrees_git_file_pointer() {
let tmp = tempfile::tempdir().unwrap();
let main = make_repo(tmp.path(), "m");
let wt = tmp.path().join("wt");
run_git(
&main,
&["worktree", "add", "-q", "-b", "b2", &wt.to_string_lossy()],
);
assert!(head_mtime(&wt).is_some());
}
fn recent(cwd: &str, cmd: &str, at: &str) -> RecentDir {
RecentDir {
cwd: PathBuf::from(cwd),
last_command: cmd.into(),
last_used: DateTime::parse_from_rfc3339(at)
.unwrap()
.with_timezone(&Utc),
}
}
#[test]
fn to_projects_merges_history_and_sorts_newest_first() {
let repo_old = DiscoveredRepo {
path: PathBuf::from("/w/old"),
name: "old".into(),
is_worktree: false,
base_repo: None,
// Committed recently but never spawned in.
head_mtime: Some(t("2026-08-23T10:00:00Z")),
};
let repo_hist = DiscoveredRepo {
path: PathBuf::from("/w/hist"),
name: "hist".into(),
is_worktree: false,
base_repo: None,
// Old commit…
head_mtime: Some(t("2026-08-01T10:00:00Z")),
};
let repo_stale = DiscoveredRepo {
path: PathBuf::from("/w/stale"),
name: "stale".into(),
is_worktree: false,
base_repo: None,
head_mtime: None,
};
let history = vec![
// Newest overall: a subdir of /w/hist (first match wins for it).
recent("/w/hist/crates", "opencode", "2026-08-22T09:00:00Z"),
recent("/w/hist", "claude", "2026-08-20T09:00:00Z"),
];
let projects = to_projects(&[repo_old, repo_hist, repo_stale], &history);
let paths: Vec<&str> = projects.iter().map(|p| p.path.to_str().unwrap()).collect();
assert_eq!(paths, ["/w/old", "/w/hist", "/w/stale"]);
// old: git activity wins (never spawned).
assert_eq!(projects[0].last_activity, Some(t("2026-08-23T10:00:00Z")));
assert_eq!(projects[0].last_command, None);
// hist: spawn history beats the old commit; command from the newest
// matching entry (the crates/ one, not the repo root one).
assert_eq!(projects[1].last_activity, Some(t("2026-08-22T09:00:00Z")));
assert_eq!(projects[1].last_command.as_deref(), Some("opencode"));
// stale: neither source knows anything.
assert_eq!(projects[2].last_activity, None);
assert_eq!(projects[2].last_command, None);
}
fn t(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
#[tokio::test]
async fn scanner_caches_within_ttl_and_rescans_after() {
let tmp = tempfile::tempdir().unwrap();
let mut scanner = ProjectScanner::new(
vec![tmp.path().to_path_buf()],
3,
PathBuf::from("/nonexistent"),
);
let empty = scanner.repos().await;
assert!(empty.is_empty());
make_repo(tmp.path(), "late");
// Within the TTL the cached (empty) snapshot is still served.
let still_empty = scanner.repos().await;
assert!(still_empty.is_empty());
// After the TTL lapses the rescan sees the new repo.
scanner.ttl = Duration::ZERO;
let fresh = scanner.repos().await;
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].name, "late");
}
#[tokio::test]
async fn background_refresh_honours_the_ttl() {
let tmp = tempfile::tempdir().unwrap();
let mut scanner = ProjectScanner::new(
vec![tmp.path().to_path_buf()],
3,
PathBuf::from("/nonexistent"),
);
let empty = scanner.repos().await;
assert!(empty.is_empty());
make_repo(tmp.path(), "late");
// A background tick inside the TTL must not re-walk the filesystem:
// the new repo stays invisible until the cache expires.
scanner.refresh().await;
assert!(scanner.repos().await.is_empty());
// Once the TTL lapses, the next tick picks it up.
scanner.ttl = Duration::ZERO;
scanner.refresh().await;
let fresh = scanner.repos().await;
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].name, "late");
}
#[test]
fn disabled_scanner_always_serves_empty() {
let scanner = ProjectScanner::disabled();
assert_eq!(scanner.roots, Vec::<PathBuf>::new());
}
}
+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",
);
}
}
+310
View File
@@ -0,0 +1,310 @@
//! Store of retained worktrees (#78): worktrees the daemon created whose
//! session is gone.
//!
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
//! missing or malformed files tolerated on read. The store survives a daemon
//! restart; it is the recovery path for everything else in the worktree
//! lifecycle.
//!
//! It is not an ownership record: it must never be consulted to decide whether
//! a path may be adopted. Its only job is to remember what the daemon is
//! holding so it can be listed and reclaimed.
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`],
/// so the store and the wire payload cannot drift apart.
pub use maestro_protocol::RetainedReason;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum RetainedStoreError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RetainedWorktreeRecord {
pub id: Uuid,
pub path: PathBuf,
pub base_repo: PathBuf,
pub branch: String,
pub reason: RetainedReason,
pub retained_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct RetainedStore {
dir: PathBuf,
}
impl RetainedStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.json"))
}
/// Record a retained worktree and return the entry that was written.
pub fn retain(
&self,
path: &Path,
base_repo: &Path,
branch: &str,
reason: RetainedReason,
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
let entry = RetainedWorktreeRecord {
id: Uuid::new_v4(),
path: path.to_path_buf(),
base_repo: base_repo.to_path_buf(),
branch: branch.to_string(),
reason,
retained_at: Utc::now(),
};
self.overwrite(&entry)?;
Ok(entry)
}
/// Atomically write `entry`. Creates the directory if it does not exist.
pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> {
std::fs::create_dir_all(&self.dir)?;
let final_path = self.path_for(entry.id);
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
let json = serde_json::to_vec_pretty(entry)?;
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &final_path)?;
Ok(())
}
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
/// store already lost (e.g. pruned) must not fail.
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
match std::fs::remove_file(self.path_for(id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Read every entry. Files that are missing or malformed are skipped
/// silently: a corrupt record must not hide the others, and an empty or
/// absent directory simply means nothing is retained.
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, RetainedStoreError> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(&self.dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e.into()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
out.push(worktree);
}
}
out.sort_by_key(|w| w.retained_at);
Ok(out)
}
/// Drop entries whose worktree directory no longer exists — somebody
/// removed it by hand. Returns the ids that were pruned.
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
let mut pruned = Vec::new();
for entry in self.load_all()? {
if !entry.path.exists() {
self.remove(entry.id)?;
pruned.push(entry.id);
}
}
Ok(pruned)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use std::path::PathBuf;
fn tmp_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
let p = root.join(name);
std::fs::create_dir_all(&p).expect("mkdir");
p
}
#[test]
fn retain_then_load_round_trips_every_field() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
let repo = sample_path(dir.path(), "repo");
let entry = store
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
.expect("retain");
let all = store.load_all().expect("load_all");
assert_eq!(all, vec![entry.clone()]);
assert_eq!(entry.path, wt);
assert_eq!(entry.base_repo, repo);
assert_eq!(entry.branch, "feature/login");
assert_eq!(entry.reason, RetainedReason::ForceKilled);
let age = Utc::now() - entry.retained_at;
assert!(age >= Duration::zero() && age < Duration::minutes(1));
}
#[test]
fn reason_serializes_kebab_case() {
let json =
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
assert_eq!(json, r#""exited-with-unsaved-work""#);
}
#[test]
fn entries_survive_a_restart() {
let dir = tmp_dir();
let wt = sample_path(dir.path(), "wt");
let first = RetainedStore::new(dir.path());
let entry = first
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
.expect("retain");
// A fresh store over the same state_dir — what a daemon restart sees.
let second = RetainedStore::new(dir.path());
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
}
#[test]
fn missing_store_reads_as_empty() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path().join("does-not-exist"));
assert_eq!(store.load_all().expect("load_all"), Vec::new());
}
#[test]
fn malformed_entries_are_skipped_silently() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
store
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
.expect("retain");
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
let all = store.load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].branch, "kept");
}
#[test]
fn ids_are_unique() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let a = store
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
.expect("retain");
let b = store
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
.expect("retain");
assert_ne!(a.id, b.id);
}
#[test]
fn remove_drops_the_entry() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
let entry = store
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
.expect("retain");
store.remove(entry.id).expect("remove");
assert_eq!(store.load_all().expect("load_all"), Vec::new());
// Removing an unknown id is a no-op, not an error.
store.remove(entry.id).expect("remove again");
}
#[test]
fn load_all_is_sorted_by_time() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let mut first = store
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
.expect("retain");
first.retained_at -= Duration::hours(1);
store.overwrite(&first).expect("rewrite");
let second = store
.retain(
dir.path(),
dir.path(),
"second",
RetainedReason::ForceKilled,
)
.expect("retain");
let branches: Vec<_> = store
.load_all()
.expect("load_all")
.into_iter()
.map(|e| e.branch)
.collect();
assert_eq!(branches, vec!["first", "second"]);
let _ = second;
}
#[test]
fn prune_missing_drops_gone_directories_only() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let alive = sample_path(dir.path(), "alive");
let gone = sample_path(dir.path(), "gone");
let keep = store
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
.expect("retain");
let drop = store
.retain(
&gone,
dir.path(),
"drop",
RetainedReason::ExitedWithUnsavedWork,
)
.expect("retain");
std::fs::remove_dir_all(&gone).expect("rm");
let pruned = store.prune_missing().expect("prune_missing");
assert_eq!(pruned, vec![drop.id]);
let branches: Vec<_> = store
.load_all()
.expect("load_all")
.into_iter()
.map(|e| e.branch)
.collect();
assert_eq!(branches, vec!["keep"]);
let _ = keep;
}
}
+546 -117
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,46 +12,50 @@ use std::time::Duration;
use chrono::Utc;
use maestro_protocol::{
ActivityState, CreateSessionRequest, ErrorInfo, Session, SessionStatus, WorktreeSpec,
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason,
RetainedWorktree, 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),
}
/// How often the liveness watcher re-probes worktrees for the blocked-work
/// indicator (#83). Each probe costs three git subprocesses per session, so
/// the pass rides the same tick as everything else but runs on a slower
/// cadence: an indicator a few seconds stale still warns before the kill
/// attempt, which is all #83 needs.
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// Per-session daemon-local bookkeeping that complements the public `Session`.
#[derive(Debug)]
pub struct SessionRecord {
pub session: Session,
pub fifo_path: PathBuf,
/// Worktree we created at spawn time and that should be cleaned up on
/// kill (if `cleanup_worktree_on_kill`).
/// Worktree we created at spawn time; removed on kill, but only after the
/// kill guard (#79) found nothing worth keeping in it.
pub worktree_path: Option<PathBuf>,
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo.
pub worktree_base_repo: Option<PathBuf>,
pub cleanup_worktree_on_kill: bool,
/// Branch the worktree was created for, recorded at spawn time so a
/// forced kill (#81) can name it in the retained store without asking
/// git. Sessions adopted from a sidecar have no branch on record and
/// fall back to a probe.
pub worktree_branch: Option<String>,
pub bridge: PtyBridge,
}
@@ -67,8 +71,18 @@ 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),
/// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
KillGuarded(maestro_protocol::InTheWay),
/// A forced kill (#81) records the retained worktree *before* tearing
/// anything down; if that write fails nothing was destroyed, and the
/// error says so rather than silently dropping unrecorded work.
#[error("could not record the retained worktree: {0}")]
RetainFailed(String),
}
impl SessionError {
@@ -79,11 +93,18 @@ impl SessionError {
SessionError::Sidecar(_) => "sidecar_failed",
SessionError::Io(_) => "io_failed",
SessionError::NotFound(_) => "not_found",
SessionError::WrongDaemon { .. } => "wrong_daemon",
SessionError::InvalidEnv(_) => "invalid_env",
SessionError::KillGuarded(_) => "kill_guarded",
SessionError::RetainFailed(_) => "retain_failed",
};
let unsaved_work = match self {
SessionError::KillGuarded(b) => Some(*b),
_ => None,
};
ErrorInfo {
code: code.into(),
message: self.to_string(),
unsaved_work,
}
}
}
@@ -96,8 +117,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,
@@ -108,20 +128,21 @@ struct Inner {
/// `None` when not wired (tests, or before `with_spawn_history`); a present
/// but disabled (`0`-day) history no-ops internally.
spawn_history: Option<SpawnHistory>,
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
/// kill (#81) without one fails rather than destroying unrecorded work.
retained: Option<crate::retained_store::RetainedStore>,
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 +154,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,13 +163,13 @@ impl SessionManager {
let (events, _) = broadcast::channel(EVENTS_CAPACITY);
Self {
inner: Arc::new(Inner {
daemon_id: daemon_id.into(),
tmux,
sidecar,
worktree_mgr,
fifo_dir,
replay_bytes,
spawn_history: None,
retained: None,
sessions: Mutex::new(HashMap::new()),
events,
}),
@@ -157,21 +177,29 @@ 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
/// Attach the retained-worktree store (#78). A forced kill (#81) needs it
/// to record what it retained; without one, forcing a guarded kill fails
/// instead of destroying unrecorded work.
pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_retained_store must be called before the SessionManager is cloned");
inner.retained = Some(store);
self
}
/// Recent working dirs for the spawn form's picker (#24), newest-first and
@@ -184,7 +212,19 @@ impl SessionManager {
.unwrap_or_default()
}
pub fn events(&self) -> broadcast::Receiver<SessionEvent> {
/// Every deduped recent dir, newest-first, without the picker's result
/// cap (#54): project enrichment folds the whole history.
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
self.inner
.spawn_history
.as_ref()
.map(|h| h.all_recent_dirs())
.unwrap_or_default()
}
/// 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,34 +256,36 @@ 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, ..
}) => {
let path = self
.inner
.worktree_mgr
.add(base_repo, branch)
.map_err(SessionError::Worktree)?;
(path.clone(), Some(path), Some(base_repo.clone()))
Some(spec) => {
let WorktreeSpec {
base_repo, branch, ..
} = spec;
// A path collision is sidestepped inside `run_add` (#77), and
// a branch already checked out elsewhere is git's refusal,
// not ours.
let path = if spec.create_branch {
self.inner.worktree_mgr.add_new(base_repo, branch)
} else {
self.inner.worktree_mgr.add_existing(base_repo, branch)
}
.map_err(SessionError::Worktree)?;
// Record the repository, not whatever the request named: a
// spawn from inside a linked worktree passes that worktree as
// `base_repo`, and clients group sessions by this path (#99).
let repo = crate::worktree::main_checkout_of(base_repo);
(path.clone(), Some(path), Some(repo))
}
None => (req.cwd.clone(), None, None),
};
let cleanup_worktree_on_kill = req
.worktree
.as_ref()
.map(|w| w.cleanup_on_kill)
.unwrap_or(false);
let id = Uuid::new_v4();
// Build the FIFO + reader task *before* asking tmux to spawn the
// command — otherwise the child's first bytes race the separate
@@ -262,12 +304,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,11 +336,16 @@ impl SessionManager {
let session = Session {
id,
daemon_id: self.inner.daemon_id.clone(),
cwd: effective_cwd,
command: req.command,
env: req.env,
metadata: req.metadata,
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
|(path, base_repo)| maestro_protocol::SessionWorktree {
path: path.clone(),
base_repo: base_repo.clone(),
},
),
status: SessionStatus::Running,
created_at: Utc::now(),
// A freshly-spawned session is producing its initial output; the
@@ -299,6 +355,9 @@ impl SessionManager {
last_activity_at: Utc::now(),
current_cwd: None,
// The watcher fills this in on its first blocked-work pass; a
// fresh tree has nothing in the way anyway.
blocked_work: None,
};
if let Err(e) = self.inner.sidecar.write(&session) {
@@ -320,12 +379,14 @@ impl SessionManager {
fifo_path,
worktree_path,
worktree_base_repo,
cleanup_worktree_on_kill,
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
bridge,
},
);
}
let _ = self.inner.events.send(SessionEvent::Added(session.clone()));
let _ = self.inner.events.send(CatalogEvent::SessionAdded {
session: session.clone(),
});
// Best-effort: log the spawn for the recents picker (#24). A log-write
// failure must never fail an otherwise-successful spawn, so the error
@@ -360,16 +421,78 @@ 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`.
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
/// Kill a session and remove its on-disk artifacts.
///
/// Refused (#79) when the session's worktree holds unsaved work —
/// uncommitted tracked changes, untracked non-ignored files, or commits
/// reachable from no remote-tracking ref. Nothing is killed and nothing
/// is removed; the error names what blocked it and how much of it there
/// is. The session stays as the operator's handle on their own unfinished
/// work, so ending a session that still matters becomes a deliberate
/// `--force`, which retains rather than destroys (WORKTREES.md).
///
/// With `force` (#81) the refusal — and nothing else — is overridden:
/// the session ends and the guarded worktree is retained into the store
/// with reason `force-killed`, never removed. The outcome names what was
/// kept so a client can say where the work went. A tree whose guard is
/// clear never needed the override; force removes it as usual.
///
/// 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, force: bool) -> Result<KillOutcome, SessionError> {
let (worktree, alive, known_branch) = {
let sessions = self.inner.sessions.lock().await;
match sessions.get(&id) {
None => return Err(SessionError::NotFound(id)),
Some(record) => (
record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone()),
record.session.status == SessionStatus::Running,
record.worktree_branch.clone(),
),
}
};
let probe = match &worktree {
Some(wt) => Some(probe_worktree(wt).await?),
None => None,
};
let guarded = probe.as_ref().is_some_and(|p| !p.is_clear());
if guarded && !force {
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
}
let mut retained = None;
if guarded {
let entry = record_retention(
&self.inner,
worktree.as_ref().expect("guarded ⇒ worktree"),
known_branch,
RetainedReason::ForceKilled,
)
.await?;
retained = Some((entry, probe.expect("guarded ⇒ probed")));
}
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;
Ok(())
cleanup_record(&self.inner, id, record, alive, retained.is_some()).await;
Ok(KillOutcome {
retained: retained.map(|(entry, in_the_way)| RetainedWorktree {
id: entry.id,
path: entry.path,
base_repo: entry.base_repo,
branch: entry.branch,
reason: entry.reason,
retained_at: entry.retained_at,
in_the_way,
}),
})
}
/// Spawn a background task that polls tmux every `interval` and reaps any
@@ -380,6 +503,17 @@ impl SessionManager {
/// `SessionManager` clone is dropped — call this once from the daemon
/// startup path after [`Self::recover`] has populated the map.
pub fn start_liveness_watcher(&self, interval: Duration) {
self.start_liveness_watcher_with_blocked_work(interval, DEFAULT_BLOCKED_WORK_INTERVAL);
}
/// [`Self::start_liveness_watcher`] with the blocked-work cadence (#83)
/// made explicit. Tests pass a small `blocked_work_interval` to watch the
/// probe actually happen.
pub fn start_liveness_watcher_with_blocked_work(
&self,
interval: Duration,
blocked_work_interval: Duration,
) {
let weak = Arc::downgrade(&self.inner);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
@@ -387,6 +521,9 @@ impl SessionManager {
// first tick fires immediately — skip it so we don't reap before
// the caller has a chance to create anything.
ticker.tick().await;
// Start "due" so the first live tick probes: a recovered daemon
// should not wait a full interval to show what blocks.
let mut since_probe = blocked_work_interval;
loop {
ticker.tick().await;
let Some(inner) = weak.upgrade() else {
@@ -396,6 +533,11 @@ impl SessionManager {
// Same tick, no new timer (NAP-340): refresh activity for the
// survivors and emit SessionUpdated on any transition.
run_activity_pass(&inner).await;
since_probe += interval;
if since_probe >= blocked_work_interval {
since_probe = Duration::ZERO;
run_blocked_work_pass(&inner).await;
}
}
});
}
@@ -415,15 +557,15 @@ 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(),
metadata: Default::default(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
// Activity is unknown on adoption; the watcher fills it in.
@@ -432,21 +574,9 @@ impl SessionManager {
last_activity_at: Utc::now(),
current_cwd: None,
blocked_work: 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?;
@@ -464,21 +594,34 @@ impl SessionManager {
SessionRecord {
session: session.clone(),
fifo_path,
worktree_path: None,
worktree_base_repo: None,
cleanup_worktree_on_kill: false,
// Restored from the sidecar: without it the recovered
// session cleans up nothing on kill, and the leftover
// directory blocks that branch forever (#54 review).
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
// The sidecar carries no branch; a forced kill of a
// recovered session probes git for it at retain time.
worktree_branch: None,
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 +630,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 +648,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,
@@ -513,6 +678,49 @@ where
.expect("tmux blocking task panicked")
}
/// What would be lost if this session's worktree were removed right now.
///
/// The single [`crate::worktree_probe::in_the_way`] call site behind both
/// endings of a session — the kill guard (#79) and the natural-exit
/// classifier (#82) — so neither can grow its own answer. Runs on the
/// blocking pool; callers invoke it before anything is torn down, because
/// refusing (#79), recording a retention (#81) and classifying an exit (#82)
/// must all see the tree as it stands.
async fn probe_worktree(
(_, path): &(PathBuf, PathBuf),
) -> Result<maestro_protocol::InTheWay, SessionError> {
let path = path.clone();
tmux_blocking(move || crate::worktree_probe::in_the_way(&path))
.await
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
}
/// Record a guarded worktree into the retained store with `reason`, BEFORE
/// the caller tears anything down: a failed write then leaves every artifact
/// untouched instead of destroying work nothing records anymore. Shared by
/// the forced kill (`ForceKilled`, #81) and the natural exit
/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart.
async fn record_retention(
inner: &Arc<Inner>,
(base_repo, path): &(PathBuf, PathBuf),
known_branch: Option<String>,
reason: RetainedReason,
) -> Result<crate::retained_store::RetainedWorktreeRecord, SessionError> {
let store = inner
.retained
.clone()
.ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?;
// Sessions adopted from a sidecar carry no branch; ask the tree itself.
let branch = known_branch
.or_else(|| crate::worktree_probe::current_branch(path))
.unwrap_or_default();
let base_repo = base_repo.clone();
let path = path.clone();
tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason))
.await
.map_err(|e| SessionError::RetainFailed(e.to_string()))
}
/// One pass of the liveness watcher: diff the in-memory session set against
/// the tmux-reported one, and reap anything we still track but tmux has lost.
async fn run_liveness_pass(inner: &Arc<Inner>) {
@@ -520,13 +728,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 +759,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 +773,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 +806,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 +838,197 @@ 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 = {
let mut sessions = inner.sessions.lock().await;
sessions.remove(&id)
/// Refresh each running session's blocked-work indicator (#83): what its
/// worktree holds right now that would block an unforced kill. Rides the
/// liveness tick on its own (slower) cadence — see
/// [`DEFAULT_BLOCKED_WORK_INTERVAL`] — because every probe is three git
/// subprocesses per session.
///
/// Sessions without a worktree are filtered out BEFORE any probe: they can
/// never block a kill, so their indicator must cost nothing at all. A failed
/// probe keeps the previous value rather than clearing it — a transient git
/// error must not read as "nothing blocks". Like the activity pass, this only
/// emits `SessionUpdated` when the value actually changed.
async fn run_blocked_work_pass(inner: &Arc<Inner>) {
let targets: Vec<(Uuid, (PathBuf, PathBuf))> = {
let sessions = inner.sessions.lock().await;
sessions
.iter()
.filter(|(_, r)| r.session.status == SessionStatus::Running)
.filter_map(|(id, r)| {
let wt = r.worktree_base_repo.clone().zip(r.worktree_path.clone())?;
Some((*id, wt))
})
.collect()
};
let Some(record) = record else {
if targets.is_empty() {
return;
}
for (id, wt) in targets {
// The kill guard's own probe (#79), so the indicator can never
// disagree with what a kill attempt would find.
let breakdown = match probe_worktree(&wt).await {
Ok(breakdown) => breakdown,
Err(e) => {
tracing::debug!(%id, error = ?e, "blocked-work: probe failed; keeping previous value");
continue;
}
};
let updated = {
let mut sessions = inner.sessions.lock().await;
let Some(rec) = sessions.get_mut(&id) else {
continue;
};
if rec.session.blocked_work == Some(breakdown) {
None
} else {
rec.session.blocked_work = Some(breakdown);
Some(rec.session.clone())
}
};
if let Some(session) = updated {
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
}
}
}
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
/// and settle its worktree per the natural-exit rule (#82).
///
/// 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. The worktree, though, is settled right away: a pristine tree is
/// removed, anything else is retained into the store with reason
/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here,
/// so there is nothing to refuse. Once settled, the session drops its
/// worktree reference so a later dismissal neither re-probes nor retains
/// twice. When settling is impossible (probe or store failure) nothing is
/// destroyed: the tree stays attached to the dead record for a later forced
/// dismissal (#81).
///
/// 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 flipped = {
let mut sessions = inner.sessions.lock().await;
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();
let session = record.session.clone();
let worktree = record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone());
let branch = record.worktree_branch.clone();
Some((session, worktree, branch))
}
_ => None,
}
};
let Some((mut session, worktree, branch)) = flipped 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");
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt, branch).await {
// The tree is settled (removed or retained); a stale "in the
// way" would describe something the session no longer holds.
session.blocked_work = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
record.session.blocked_work = None;
record.worktree_path = None;
record.worktree_base_repo = None;
record.worktree_branch = None;
}
}
}
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
}
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
/// would be lost through the kill's own predicate, then remove a pristine
/// tree or retain anything else under `exited-with-unsaved-work` — recorded
/// before anything is torn down, exactly as the forced kill does (#81). A
/// removal that fails falls back to retaining, since whatever blocked it is
/// by definition something worth keeping.
///
/// Returns whether the worktree was fully accounted for. When it wasn't,
/// nothing was destroyed and the caller keeps the reference on the record.
async fn settle_worktree_on_exit(
inner: &Arc<Inner>,
id: Uuid,
(base_repo, path): &(PathBuf, PathBuf),
known_branch: Option<String>,
) -> bool {
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
Ok(probe) => probe,
Err(e) => {
tracing::error!(
%id,
path = %path.display(),
error = ?e,
"natural exit: could not probe the worktree; leaving it untouched"
);
return false;
}
};
if probe.is_clear() {
match inner.worktree_mgr.remove(base_repo, path) {
Ok(()) => {
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
return true;
}
Err(e) => {
tracing::warn!(
%id,
path = %path.display(),
error = ?e,
"natural exit: removal failed; falling back to retention"
);
}
}
}
match record_retention(
inner,
&(base_repo.clone(), path.clone()),
known_branch,
RetainedReason::ExitedWithUnsavedWork,
)
.await
{
Ok(entry) => {
tracing::info!(
%id,
path = %path.display(),
entry = %entry.id,
"natural exit: worktree retained with unsaved work"
);
true
}
Err(e) => {
tracing::error!(
%id,
path = %path.display(),
error = ?e,
"natural exit: could not record the retained worktree; leaving it untouched"
);
false
}
}
}
/// Tear down a session's artifacts after it has already been removed from the
@@ -636,7 +1040,17 @@ async fn reap_dead_session(inner: &Arc<Inner>, id: Uuid) {
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
/// liveness reaper (tmux already lost the session).
async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kill_tmux: bool) {
///
/// `retain_worktree` is `true` for a forced kill (#81) that retained the
/// worktree instead of removing it: the store entry is already written, so
/// only the removal is skipped.
async fn cleanup_record(
inner: &Arc<Inner>,
id: Uuid,
record: SessionRecord,
kill_tmux: bool,
retain_worktree: bool,
) {
if kill_tmux {
let tmux = inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
@@ -645,10 +1059,25 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
if let Err(e) = inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
if record.cleanup_worktree_on_kill {
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
let _ = inner.worktree_mgr.remove(base, path);
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if retain_worktree {
tracing::info!(
%id,
path = %path.display(),
"forced kill: worktree retained in the store"
);
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
// The kill already passed the unsaved-work guard (#79), so this
// removal is unconditional; the manager's own nested-worktree
// refusal is all that can still leave a tree on disk, and it
// says where.
tracing::warn!(
%id,
path = %path.display(),
error = %e,
"left worktree on disk"
);
}
}
let _ = inner.events.send(SessionEvent::Removed(id));
let _ = inner.events.send(CatalogEvent::SessionRemoved { id });
}
+38 -2
View File
@@ -143,11 +143,23 @@ impl SpawnHistory {
/// Recent working dirs, newest use first, deduped by `cwd`, within the
/// recency cap, capped at `max_results`. Empty when disabled or unreadable.
pub fn recent_dirs(&self) -> Vec<RecentDir> {
self.recent_dirs_as_of(Utc::now())
self.recent_dirs_capped(Utc::now(), self.max_results)
}
/// Every deduped recent dir, newest-first, without the picker's result
/// cap (#54): project enrichment folds the whole history, not its first
/// page, so a project spawned in long ago still reads as fresh.
pub fn all_recent_dirs(&self) -> Vec<RecentDir> {
self.recent_dirs_capped(Utc::now(), usize::MAX)
}
/// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing.
#[cfg(test)]
fn recent_dirs_as_of(&self, now: DateTime<Utc>) -> Vec<RecentDir> {
self.recent_dirs_capped(now, self.max_results)
}
fn recent_dirs_capped(&self, now: DateTime<Utc>, max_results: usize) -> Vec<RecentDir> {
if !self.is_enabled() {
return Vec::new();
}
@@ -168,7 +180,7 @@ impl SpawnHistory {
last_command: rec.command,
last_used: rec.at,
});
if out.len() >= self.max_results {
if out.len() >= max_results {
break;
}
}
@@ -362,4 +374,28 @@ mod tests {
assert_eq!(recents[0].cwd, PathBuf::from("/d4"));
assert_eq!(recents[1].cwd, PathBuf::from("/d3"));
}
/// Project enrichment (#54) folds the whole log, not just the picker's
/// first page.
#[test]
fn all_recent_dirs_skips_the_result_cap() {
let tmp = tempfile::tempdir().unwrap();
let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30);
h.max_results = 2;
// Recent enough to survive the 30-day cap against the real clock.
let base = Utc::now() - Duration::hours(1);
for i in 0..5 {
h.append(&rec(
&format!("/d{i}"),
"claude",
base + Duration::minutes(i),
))
.unwrap();
}
assert_eq!(h.recent_dirs().len(), 2);
let all = h.all_recent_dirs();
assert_eq!(all.len(), 5);
assert_eq!(all[0].cwd, PathBuf::from("/d4"));
assert_eq!(all[4].cwd, PathBuf::from("/d0"));
}
}
+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();
+238 -40
View File
@@ -4,79 +4,277 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum WorktreeError {
#[error("failed to spawn git: {0}")]
Spawn(#[from] std::io::Error),
#[error("git exited with code {code}: {stderr}")]
NonZero { code: i32, stderr: String },
/// A registered worktree lives under the tree about to be removed; git
/// hides its contents from `git status`, so removal would delete its
/// uncommitted work unseen (#54 review).
#[error("left {} on disk: {}", .path.display(), .reason)]
Guarded { path: PathBuf, reason: String },
}
/// Generates a unique path under `root` and runs `git worktree add` rooted at
/// `base_repo`. Each call appends a UUID-suffixed directory so concurrent
/// `add()`s never collide.
impl From<crate::worktree_probe::ProbeError> for WorktreeError {
fn from(e: crate::worktree_probe::ProbeError) -> Self {
match e {
crate::worktree_probe::ProbeError::Spawn(io) => WorktreeError::Spawn(io),
crate::worktree_probe::ProbeError::NonZero { code, stderr } => {
WorktreeError::NonZero { code, stderr }
}
}
}
}
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
/// are findable from the repo; an operator-set root overrides that.
#[derive(Debug, Clone)]
pub struct WorktreeManager {
root: PathBuf,
/// `[daemon].worktree_root` override. `None` uses the default
/// `<repo>/worktrees/<branch>` placement.
root_override: Option<PathBuf>,
}
impl WorktreeManager {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
pub fn new(root_override: Option<PathBuf>) -> Self {
Self { root_override }
}
pub fn root(&self) -> &Path {
&self.root
pub fn root(&self) -> Option<&Path> {
self.root_override.as_deref()
}
/// `git -C <base_repo> worktree add -b <branch> <generated-path>`.
/// Returns the path of the new worktree on success.
pub fn add(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
std::fs::create_dir_all(&self.root)?;
let path = self.generate_path(branch);
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["worktree", "add", "-b", branch])
.arg(&path)
.output()?;
/// Where a worktree for `branch` will be created: the override root, or
/// `<main checkout>/worktrees/<sanitized branch>` by default. If the path
/// is occupied, [`Self::add_new`] / [`Self::add_existing`] sidestep to
/// `<path>-2`, `<path>-3`, ... instead.
///
/// `base_repo` is whatever the client picked, which the project list makes
/// easy to point at a *linked worktree* — placing the new tree inside it
/// would nest one worktree in another, and git hides a registered
/// worktree's contents from the outer `git status`, so the outer tree
/// would later look clean and its removal would delete the inner one's
/// uncommitted work (#54 review). Always place against the main checkout.
///
/// The override root gets the repo's basename too: without it two repos
/// spawning the same branch name target one directory.
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
// An unusable branch name falls back to a digest rather than escaping
// the root; `run_add` surfaces git's own error for it anyway.
let slug = branch_relpath(branch).unwrap_or_else(|| PathBuf::from(short_hash(branch)));
let main = main_checkout(base_repo);
match &self.root_override {
Some(root) => {
// Basename alone collides for two checkouts of the same name
// (`~/a/maestro` and `~/b/maestro`), so disambiguate with a
// digest of the full path (#54 review).
let base = main
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".into());
let repo = format!("{base}-{}", short_hash(&main.to_string_lossy()));
root.join(repo).join(slug)
}
None => main.join("worktrees").join(slug),
}
}
/// [`Self::target_path`] with `-n` appended to the final component, the
/// collision suffix (#77).
fn target_path_suffixed(&self, base_repo: &Path, branch: &str, n: u32) -> PathBuf {
let mut name = self.target_path(base_repo, branch).into_os_string();
name.push(format!("-{n}"));
PathBuf::from(name)
}
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
/// branch. Returns the path of the new worktree on success.
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
self.run_add(base_repo, branch, true)
}
/// `git -C <base_repo> worktree add <path> <branch>` — checks out a branch
/// that must already exist (#56).
pub fn add_existing(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
self.run_add(base_repo, branch, false)
}
fn run_add(
&self,
base_repo: &Path,
branch: &str,
create_branch: bool,
) -> Result<PathBuf, WorktreeError> {
// Clear registrations whose directory is gone first, so a
// previously-leaked entry doesn't push this branch to a suffix.
prune(base_repo)?;
// Anything occupying the target — a tree we left behind, one the
// operator made, an unrelated directory — is sidestepped with a
// numeric suffix on the directory; the branch stays whatever was
// requested (#77). The daemon never adopts what it finds.
let mut path = self.target_path(base_repo, branch);
let mut n = 1;
while path.symlink_metadata().is_ok() {
n += 1;
path = self.target_path_suffixed(base_repo, branch, n);
}
// The parent (`<repo>/worktrees` or the override root) may not exist
// yet; the target itself must not.
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut cmd = Command::new("git");
cmd.arg("-C").arg(base_repo).args(["worktree", "add"]);
if create_branch {
cmd.arg("-b").arg(branch).arg(&path);
} else {
// Existing branch: `worktree add <path> <branch>` with no `-b`.
cmd.arg(&path).arg(branch);
}
let out = cmd.output()?;
check(out)?;
Ok(path)
}
/// `git -C <base_repo> worktree remove --force <path>`.
/// `git -C <base_repo> worktree remove <path>`.
///
/// Unconditional (#79): the guard moved off the worktree and onto the
/// kill, so by the time removal runs the caller has already chosen to
/// destroy the tree — the kill only gets here after
/// [`crate::worktree_probe::in_the_way`] came back clear. Still refused for a tree holding a
/// nested registered worktree, which `git status` cannot see.
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
// Gone from disk already (operator `rm -rf`, or a create that failed
// half-way). Probing it with git would fail and be misread as unsaved
// work, stranding the registration — and since paths are
// deterministic, `worktree add` would then refuse that branch forever
// with "missing but already registered" (#56 review).
if !path.exists() {
return prune(base_repo);
}
if let Some(inner) = nested_worktree(path) {
return Err(WorktreeError::Guarded {
path: path.to_path_buf(),
reason: format!("holds a nested worktree at {}", inner.display()),
});
}
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["worktree", "remove", "--force"])
.args(["worktree", "remove"])
.arg(path)
.output()?;
check(out)
}
fn generate_path(&self, branch: &str) -> PathBuf {
let slug = sanitize_branch_for_path(branch);
let unique = Uuid::new_v4().simple();
self.root.join(format!("{slug}-{unique}"))
}
}
fn sanitize_branch_for_path(branch: &str) -> String {
let mut out = String::with_capacity(branch.len());
for c in branch.chars() {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
out.push(c);
} else if c == '/' || c == '.' {
out.push('-');
}
/// The repository's main checkout. `--git-common-dir` points at the shared
/// `.git` for every linked worktree, so its parent is the main tree. Falls
/// back to the input when git can't answer.
/// The repository a path belongs to: the main checkout, even when `path` is a
/// linked worktree. Clients group sessions by this (#99), and placement uses it
/// so a worktree is never nested inside another.
pub fn main_checkout_of(path: &Path) -> PathBuf {
main_checkout(path)
}
fn main_checkout(path: &Path) -> PathBuf {
let out = Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.output();
let Ok(out) = out else {
return path.to_path_buf();
};
if !out.status.success() {
return path.to_path_buf();
}
if out.is_empty() {
out.push_str("wt");
let common = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim().to_string());
common
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| path.to_path_buf())
}
/// `Some(inner)` when a registered worktree lives under `path`. Git omits
/// those directories from `git status`, so the dirty-work guard cannot see
/// them and removal would delete them (#54 review).
fn nested_worktree(path: &Path) -> Option<PathBuf> {
let out = Command::new("git")
.arg("-C")
.arg(path)
.args(["worktree", "list", "--porcelain"])
.output()
.ok()?;
// `worktree list` prints fully-resolved paths while ours comes from
// `--git-common-dir` and may still hold symlinks. Comparing the two forms
// directly makes the guard silently pass on any symlinked repo path — and
// this guard is what stands between a kill and deleting an inner
// worktree's uncommitted work (#54 review).
let needle = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.filter_map(|l| l.strip_prefix("worktree "))
.map(PathBuf::from)
.find(|p| {
let real = p.canonicalize().unwrap_or_else(|_| p.clone());
real != needle && real.starts_with(&needle)
})
}
/// Short digest used to disambiguate override roots.
///
/// Hand-rolled FNV-1a rather than `DefaultHasher`: this value becomes a
/// directory name, and `DefaultHasher`'s algorithm is explicitly not
/// guaranteed stable across Rust releases — a toolchain bump would remap every
/// repo to a new directory and orphan the worktrees under the old one
/// (#67 review).
fn short_hash(s: &str) -> String {
let mut h: u32 = 0x811c_9dc5;
for b in s.as_bytes() {
h ^= u32::from(*b);
h = h.wrapping_mul(0x0100_0193);
}
out
format!("{h:08x}")
}
/// The relative directory a branch's worktree lives in.
///
/// A git branch name is already a valid relative path — git stores it as
/// `.git/refs/heads/feat/example` — so it is used verbatim and `feat/example`
/// nests under `worktrees/feat/`. That keeps the path readable *and* keeps the
/// mapping injective: flattening `/` and `.` to `-` made `feat/x`, `feat.x`
/// and `feat-x` share one directory (#54 review).
///
/// Git already forbids holding both `feat` and `feat/x`, so the directory
/// collision that nesting could cause cannot arise from valid refs. Only
/// traversal and absolute components are rejected.
fn branch_relpath(branch: &str) -> Option<PathBuf> {
use std::path::Component;
let candidate = Path::new(branch);
if branch.is_empty()
|| !candidate
.components()
.all(|c| matches!(c, Component::Normal(_)))
{
return None;
}
Some(candidate.to_path_buf())
}
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
/// no longer exists.
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
.args(["worktree", "prune"])
.output()?;
check(out)
}
fn check(out: Output) -> Result<(), WorktreeError> {
+325
View File
@@ -0,0 +1,325 @@
//! The one git probe for what a worktree holds that would be lost, plus
//! guard-free reclaim (#79, #80).
//!
//! [`in_the_way`] is the single predicate behind all three paths: the kill
//! refusal (#79), the natural-exit classifier (#82), and the retained list
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
//! answer the question differently — #79 and #80 were built in parallel and
//! each grew its own copy, which is exactly the drift this collapses.
//!
//! Reclaim never consults it: reclaim is the deliberate destructive act, and
//! the operator chose it while looking at what was in the way.
use std::path::Path;
use maestro_protocol::InTheWay;
#[derive(Debug, thiserror::Error)]
pub enum ProbeError {
#[error("failed to spawn git: {0}")]
Spawn(#[from] std::io::Error),
#[error("git exited with code {code}: {stderr}")]
NonZero { code: i32, stderr: String },
}
/// What a kill refusal would report about the worktree at `path`, right now.
///
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
/// `target/`, and counting those would put every tree permanently in the way.
/// A directory that no longer exists reports nothing in the way: there is
/// nothing left to lose, and the store's own pruning drops the entry.
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
let mut breakdown = InTheWay::default();
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
// our purposes, and following it would probe the wrong tree.
if std::fs::symlink_metadata(path).is_err() {
return Ok(breakdown);
}
let status = run_git(
path,
[
"status",
"--porcelain",
"--ignored=no",
"--untracked-files=normal",
],
)?;
for line in String::from_utf8_lossy(&status.stdout).lines() {
if line.starts_with("??") {
breakdown.untracked_files += 1;
} else if !line.is_empty() {
breakdown.uncommitted_files += 1;
}
}
// Resolved inside the worktree so a detached HEAD still has a starting
// point; asking the base repo for `refs/heads/<branch>` fails there.
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
// Commits reachable from the tip but from no remote-tracking ref. A branch
// pushed anywhere counts as pushed; there is no "which remote" question to
// get wrong.
let unpushed = run_git(path, ["rev-list", "--count", tip, "--not", "--remotes"])?;
breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout)
.trim()
.parse()
.unwrap_or(0);
Ok(breakdown)
}
/// Remove a retained worktree's directory without consulting any guard, per
/// the reclaim contract: the operator chose this while looking at what was in
/// the way. `git worktree remove` keeps `refs/heads/<branch>`, so commits on
/// the branch survive even when they were never pushed.
///
/// A directory that is already gone only leaves a stale registration behind;
/// `worktree prune` clears it so the branch can be spawned again.
pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> {
if !path.exists() {
return run_git(base_repo, ["worktree", "prune"]).map(|_| ());
}
run_git(
base_repo,
[
"worktree",
"remove",
"--force",
path.to_str().expect("worktree path is valid utf-8"),
],
)
.map(|_| ())
}
/// Branch checked out at `path`. Sessions adopted from a sidecar carry no
/// branch on record, so a forced kill (#81) asks the tree itself when it
/// needs one for the retained store. `None` when git cannot answer.
pub fn current_branch(path: &Path) -> Option<String> {
let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let mut cmd = std::process::Command::new("git");
cmd.arg("-C").arg(dir).args(args);
let out = cmd.output()?;
if !out.status.success() {
return Err(ProbeError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::process::Command;
struct Repo {
root: tempfile::TempDir,
repo: PathBuf,
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// A repo with a remote, one commit on `main`, and a linked worktree for
/// branch `feature` at `<repo>/worktrees/feature`.
fn repo_with_worktree() -> Repo {
let root = tempfile::tempdir().expect("tempdir");
let repo = root.path().join("repo");
let remote = root.path().join("remote.git");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(&remote).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["config", "user.email", "t@t"]);
git(&repo, &["config", "user.name", "T"]);
std::fs::write(repo.join("README"), b"hello\n").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "init"]);
git(&remote, &["init", "-q", "--bare"]);
git(&repo, &["remote", "add", "origin", &path_str(&remote)]);
git(&repo, &["push", "-q", "-u", "origin", "main"]);
git(
&repo,
&[
"worktree",
"add",
"-q",
"-b",
"feature",
"worktrees/feature",
],
);
Repo { root, repo }
}
fn path_str(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
impl Repo {
fn wt(&self) -> PathBuf {
self.repo.join("worktrees").join("feature")
}
fn commit_in_wt(&self, msg: &str) {
std::fs::write(self.wt().join("README"), msg.as_bytes()).unwrap();
git(&self.wt(), &["add", "-A"]);
git(&self.wt(), &["commit", "-qm", msg]);
}
}
#[test]
fn a_clean_pushed_worktree_has_nothing_in_the_way() {
let r = repo_with_worktree();
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown, InTheWay::default());
assert!(breakdown.is_clear());
}
#[test]
fn uncommitted_untracked_and_unpushed_are_counted_separately() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap();
let dirty = in_the_way(&r.wt()).expect("probe");
assert_eq!(dirty.uncommitted_files, 1);
assert_eq!(dirty.untracked_files, 1);
assert_eq!(dirty.unpushed_commits, 0);
assert!(!dirty.is_clear());
// Committing clears exactly one dimension and creates another.
git(&r.wt(), &["add", "README"]);
git(&r.wt(), &["commit", "-qm", "wip"]);
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.uncommitted_files, 0);
assert_eq!(breakdown.untracked_files, 1);
assert_eq!(breakdown.unpushed_commits, 1);
}
#[test]
fn tracked_edits_count_as_uncommitted() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(breakdown.uncommitted_files, 1);
assert_eq!(breakdown.untracked_files, 0);
}
#[test]
fn pushing_clears_unpushed_but_keeps_other_entries() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap();
r.commit_in_wt("wip");
let before = in_the_way(&r.wt()).expect("probe");
assert_eq!(before.unpushed_commits, 1);
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
// Same stored entry, different tree: the breakdown must move with it.
let after = in_the_way(&r.wt()).expect("probe");
assert_eq!(after.unpushed_commits, 0);
assert_eq!(after.untracked_files, 0);
std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap();
let dirty = in_the_way(&r.wt()).expect("probe");
assert_eq!(dirty.untracked_files, 1);
}
#[test]
fn ignored_files_never_count() {
let r = repo_with_worktree();
std::fs::write(r.wt().join(".gitignore"), b"target/\n").unwrap();
std::fs::create_dir_all(r.wt().join("target")).unwrap();
std::fs::write(r.wt().join("target/artifact"), b"x").unwrap();
let breakdown = in_the_way(&r.wt()).expect("probe");
assert_eq!(
breakdown,
InTheWay {
untracked_files: 1,
..InTheWay::default()
},
"the .gitignore itself is untracked; target/ is not"
);
}
#[test]
fn a_missing_directory_reports_nothing_in_the_way() {
let r = repo_with_worktree();
let gone = r.root.path().join("nowhere");
let breakdown = in_the_way(&gone).expect("probe");
assert!(breakdown.is_clear());
}
#[test]
fn reclaim_removes_the_directory_and_prunes_a_gone_one() {
let r = repo_with_worktree();
r.commit_in_wt("precious");
reclaim(&r.repo, &r.wt()).expect("reclaim");
assert!(!r.wt().exists());
// A second reclaim over the now-missing path just prunes.
reclaim(&r.repo, &r.wt()).expect("reclaim again");
}
#[test]
fn reclaim_keeps_the_branch_and_its_commits() {
let r = repo_with_worktree();
r.commit_in_wt("precious");
let sha = {
let out = Command::new("git")
.arg("-C")
.arg(r.wt())
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
reclaim(&r.repo, &r.wt()).expect("reclaim");
let out = Command::new("git")
.arg("-C")
.arg(&r.repo)
.args(["rev-parse", "--verify", "refs/heads/feature"])
.output()
.unwrap();
assert!(
out.status.success(),
"branch must survive reclaim: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha);
}
#[test]
fn reclaim_is_guard_free_on_a_dirty_tree() {
let r = repo_with_worktree();
std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap();
std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap();
reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard");
assert!(!r.wt().exists());
}
}
+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"
);
}
+91 -79
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,17 @@ struct Harness {
_fx: DaemonFixture,
}
async fn spawn_daemon_ws(daemon_id: &str) -> Harness {
let fx = DaemonFixture::new(daemon_id);
async fn spawn_daemon_ws() -> Harness {
let fx = DaemonFixture::new();
let manager = fx.manager.clone();
let verifier = JwtVerifier::new(TOKEN, daemon_id);
let pair_verifier = PairAttachVerifier::new(TOKEN, daemon_id);
let router = attach::router(manager.clone(), verifier, pair_verifier);
let router = local_api::router(local_api::state_for(
manager.clone(),
"test-host",
"/home/test",
Vec::new(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
std::sync::Arc::new(fx.retained_store()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
@@ -58,25 +60,12 @@ async fn spawn_daemon_ws(daemon_id: &str) -> Harness {
}
}
fn token_for(session_id: Uuid, daemon_id: &str, valid_seconds: i64) -> String {
issue_jwt(
TOKEN,
&JwtClaims {
session_id,
daemon_id: daemon_id.into(),
exp: (Utc::now() + ChronoDuration::seconds(valid_seconds)).timestamp(),
iat: Some(Utc::now().timestamp()),
},
)
.unwrap()
}
#[allow(clippy::result_large_err)]
async fn connect_attach(
addr: SocketAddr,
session: Uuid,
token: &str,
) -> Result<WsClient, tokio_tungstenite::tungstenite::Error> {
let url = format!("ws://{addr}/sessions/{session}/stream?token={token}");
let url = format!("ws://{addr}/api/attach/{session}");
connect_async(url).await.map(|(ws, _)| ws)
}
@@ -86,7 +75,6 @@ async fn create_bash_session(h: &Harness) -> Uuid {
let s = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd_path,
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -100,42 +88,17 @@ async fn create_bash_session(h: &Harness) -> Uuid {
}
#[tokio::test]
async fn rejects_bad_token() {
let h = spawn_daemon_ws("laptop").await;
let id = create_bash_session(&h).await;
let err = connect_attach(h.addr, id, "not-a-jwt").await.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("401") || msg.contains("403"), "{msg}");
let _ = h.manager.kill(id).await;
}
#[tokio::test]
async fn rejects_token_for_different_session() {
let h = spawn_daemon_ws("laptop").await;
let real_id = create_bash_session(&h).await;
let other_id = Uuid::new_v4();
let token = token_for(other_id, "laptop", 300);
let err = connect_attach(h.addr, real_id, &token).await.unwrap_err();
assert!(format!("{err}").contains("403"));
let _ = h.manager.kill(real_id).await;
}
#[tokio::test]
async fn rejects_token_for_different_daemon() {
let h = spawn_daemon_ws("laptop").await;
let id = create_bash_session(&h).await;
let token = token_for(id, "dev-vm", 300);
let err = connect_attach(h.addr, id, &token).await.unwrap_err();
assert!(format!("{err}").contains("401"));
let _ = h.manager.kill(id).await;
async fn rejects_unknown_session() {
let h = spawn_daemon_ws().await;
let err = connect_attach(h.addr, Uuid::new_v4()).await.unwrap_err();
assert!(format!("{err}").contains("404"));
}
#[tokio::test]
async fn pty_round_trip_through_real_tmux() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
ws.send(WsMessage::Binary(b"echo MAESTRO_RT_OK\n".to_vec()))
.await
@@ -159,17 +122,16 @@ async fn pty_round_trip_through_real_tmux() {
"marker not seen in {} bytes",
got.len()
);
let _ = h.manager.kill(id).await;
let _ = h.manager.kill(id, false).await;
}
#[tokio::test]
async fn multi_attach_broadcasts_to_every_client() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws_a = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws_b = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws_a = connect_attach(h.addr, id).await.unwrap();
let mut ws_b = connect_attach(h.addr, id).await.unwrap();
// give the broadcast subscribers a moment to register
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -208,7 +170,7 @@ async fn multi_attach_broadcasts_to_every_client() {
"client B missed marker"
);
let _ = h.manager.kill(id).await;
let _ = h.manager.kill(id, false).await;
}
#[tokio::test]
@@ -216,10 +178,9 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() {
// Bug NAP-226: when bash exits, tmux destroys the session, the liveness
// watcher reaps it, and the broadcast channel closes — but the WS used to
// be dropped silently, leaving the client hung in raw mode.
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -251,15 +212,14 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() {
#[tokio::test]
async fn sends_close_frame_when_session_is_killed() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
// give the broadcast subscriber a moment to register before killing.
tokio::time::sleep(Duration::from_millis(100)).await;
h.manager.kill(id).await.unwrap();
h.manager.kill(id, false).await.unwrap();
let mut saw_close = false;
let deadline = std::time::Instant::now() + Duration::from_secs(2);
@@ -287,10 +247,9 @@ async fn sends_close_frame_when_session_is_killed() {
#[tokio::test]
async fn ping_text_frame_gets_pong() {
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
let token = token_for(id, "laptop", 300);
let mut ws = connect_attach(h.addr, id, &token).await.unwrap();
let mut ws = connect_attach(h.addr, id).await.unwrap();
let ping = serde_json::to_string(&maestro_protocol::ClientControl::Ping).unwrap();
ws.send(WsMessage::Text(ping)).await.unwrap();
@@ -308,18 +267,17 @@ async fn ping_text_frame_gets_pong() {
}
}
assert!(saw_pong, "no Pong frame received");
let _ = h.manager.kill(id).await;
let _ = h.manager.kill(id, false).await;
}
#[tokio::test]
async fn second_attach_sees_replay_buffer_of_prior_output() {
// NAP-229: a fresh attach must receive the buffered scrollback before
// any live frames, so reconnecting to an idle prompt isn't blank.
let h = spawn_daemon_ws("laptop").await;
let h = spawn_daemon_ws().await;
let session = create_bash_session(&h).await;
let token = token_for(session, "laptop", 60);
let mut a = connect_attach(h.addr, session, &token).await.unwrap();
let mut a = connect_attach(h.addr, session).await.unwrap();
// Generate output the bash will echo back.
a.send(WsMessage::Binary(b"echo MAESTRO_REPLAY_MARK\n".to_vec()))
.await
@@ -349,7 +307,7 @@ async fn second_attach_sees_replay_buffer_of_prior_output() {
// Now attach a fresh client. It should see the prior MARK in the replay
// even though no new output has happened since.
let mut b = connect_attach(h.addr, session, &token).await.unwrap();
let mut b = connect_attach(h.addr, session).await.unwrap();
let saw_replay = tokio::time::timeout(std::time::Duration::from_secs(2), async {
let mut buf = Vec::new();
while let Some(Ok(msg)) = b.next().await {
@@ -369,3 +327,57 @@ async fn second_attach_sees_replay_buffer_of_prior_output() {
"fresh attach did not receive replay containing prior output"
);
}
/// The point of keeping a dead session: attach afterwards and read what it
/// printed before it died. The stream then ends rather than hanging.
#[tokio::test]
async fn attaching_to_a_dead_session_replays_its_final_output() {
let h = spawn_daemon_ws().await;
let id = create_bash_session(&h).await;
// Produce output, then let the session die out-of-band.
{
let mut ws = connect_attach(h.addr, id).await.unwrap();
ws.send(WsMessage::Binary(b"echo last-words\n".to_vec()))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
}
h.manager.start_liveness_watcher(Duration::from_millis(50));
h._fx.tmux.tmux.kill_session(id).expect("kill tmux");
// Wait for the daemon to notice.
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline {
if h.manager.status(id).await == Some(maestro_protocol::SessionStatus::Dead) {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
assert_eq!(
h.manager.status(id).await,
Some(maestro_protocol::SessionStatus::Dead),
"session should be marked dead, not removed"
);
let mut ws = connect_attach(h.addr, id)
.await
.expect("a dead session must still accept an attach");
let mut seen = Vec::new();
let deadline = std::time::Instant::now() + Duration::from_secs(3);
while std::time::Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(200), ws.next()).await {
Ok(Some(Ok(WsMessage::Binary(b)))) => seen.extend_from_slice(&b),
Ok(Some(Ok(WsMessage::Close(_)))) | Ok(None) => break,
Ok(Some(Ok(_))) => continue,
Ok(Some(Err(_))) => break,
Err(_) => continue,
}
}
let text = String::from_utf8_lossy(&seen);
assert!(
text.contains("last-words"),
"replay of a dead session lost its final output: {text:?}"
);
}
@@ -0,0 +1,116 @@
//! The performance contract of #83: the blocked-work pass must cost NO git
//! probe at all for a session without a worktree, while a session with one is
//! still probed.
//!
//! `git` itself is shimmed through PATH with a wrapper that logs every
//! invocation before exec'ing the real binary, so the assertion counts actual
//! probes rather than inferring them from state. This lives in its own test
//! binary because it mutates the process environment.
mod common;
use std::collections::HashMap;
use std::process::Command;
use std::time::Duration;
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec};
use tempfile::TempDir;
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
cwd: cwd.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.to_path_buf(),
branch: branch.into(),
create_branch: true,
}),
}
}
#[tokio::test]
async fn a_session_without_a_worktree_costs_no_probe() {
let fx = DaemonFixture::new();
let log = TempDir::new().unwrap();
let log_path = log.path().join("git-calls.log");
// Session A owns a real (pristine, pushed) worktree; session B has none.
let repo_a = TempDir::new().unwrap();
init_repo(repo_a.path());
init_bare_repo(&repo_a.path().join("origin.git"));
push_all_to_origin(repo_a.path(), &repo_a.path().join("origin.git"));
fx.manager
.create(request(repo_a.path(), repo_a.path(), "feat/probed"))
.await
.expect("create A");
let cwd_b = TempDir::new().unwrap();
let mut req_b = request(cwd_b.path(), cwd_b.path(), "feat/unused");
req_b.worktree = None;
fx.manager.create(req_b).await.expect("create B");
// Shim `git` AFTER setup so only watcher-driven probes get logged.
let real_git = String::from_utf8(
Command::new("sh")
.arg("-c")
.arg("command -v git")
.output()
.expect("locate git")
.stdout,
)
.unwrap()
.trim()
.to_string();
let shim_dir = TempDir::new().unwrap();
let script = format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexec {} \"$@\"\n",
log_path.display(),
real_git,
);
std::fs::write(shim_dir.path().join("git"), script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
shim_dir.path().join("git"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
}
let old_path = std::env::var("PATH").unwrap_or_default();
std::env::set_var(
"PATH",
format!("{}:{}", shim_dir.path().display(), old_path),
);
fx.manager.start_liveness_watcher_with_blocked_work(
Duration::from_millis(50),
Duration::from_millis(100),
);
// The pass demonstrably runs and probed A's tree...
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.snapshot()
.await
.iter()
.any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default()))
})
.await;
std::env::set_var("PATH", &old_path);
let calls = std::fs::read_to_string(&log_path).unwrap_or_default();
assert!(
!calls.is_empty(),
"the pass never probed anything — the shim saw no git call at all"
);
// ...while B — no worktree, so nothing to probe — was never touched.
assert!(
!calls.contains(cwd_b.path().to_str().unwrap()),
"a worktree-less session was probed:\n{calls}"
);
}
+142
View File
@@ -0,0 +1,142 @@
//! Integration tests for `GET /api/branches` (#55).
mod common;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use common::DaemonFixture;
use maestro_daemon::local_api;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
struct Harness {
addr: SocketAddr,
_shutdown: oneshot::Sender<()>,
_fx: DaemonFixture,
}
async fn spawn() -> Harness {
let fx = DaemonFixture::new();
let presets = maestro_config::SpawnSection::default().resolve().unwrap();
let router = local_api::router(local_api::state_for(
fx.manager.clone(),
"naps-laptop",
"/home/test",
presets,
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
std::sync::Arc::new(fx.retained_store()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
Harness {
addr,
_shutdown: tx,
_fx: fx,
}
}
fn git(repo: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// A real repo on `main` with a second branch `feature`, and a linked
/// worktree at `<tmp>/wt-feature` holding `feature`.
fn repo_with_occupied_branch() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
git(repo, &["init", "-q", "-b", "main"]);
git(
repo,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"init",
"--allow-empty",
],
);
git(repo, &["branch", "feature"]);
let wt = tmp.path().join("wt-feature");
git(repo, &["worktree", "add", &wt.to_string_lossy(), "feature"]);
tmp
}
async fn get_branches(addr: SocketAddr, path: &Path) -> Vec<maestro_protocol::BranchInfo> {
reqwest::get(format!(
"http://{addr}/api/branches?path={}",
urlencoding::encode(&path.to_string_lossy())
))
.await
.unwrap()
.json()
.await
.unwrap()
}
/// #55: every local branch is listed; the main checkout's HEAD reports
/// `is_head`, and a branch held by a linked worktree reports that worktree's
/// path so the client can explain why the entry is disabled.
#[tokio::test]
async fn branches_lists_local_branches_and_worktree_occupancy() {
let tmp = repo_with_occupied_branch();
let h = spawn().await;
let mut branches = get_branches(h.addr, tmp.path()).await;
branches.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(
branches.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
["feature", "main"]
);
let main = branches.iter().find(|b| b.name == "main").unwrap();
assert!(main.is_head, "main is the main checkout's HEAD");
assert_eq!(main.worktree, None, "HEAD occupancy rides `is_head`");
let feature = branches.iter().find(|b| b.name == "feature").unwrap();
assert!(!feature.is_head);
assert_eq!(
feature.worktree.as_deref(),
Some(tmp.path().join("wt-feature").as_path()),
"the linked worktree holding the branch must be reported"
);
}
/// #55: a path outside any repository is not an error — an empty list,
/// matching how `/api/gitroot` reports "not a repository".
#[tokio::test]
async fn branches_returns_empty_for_a_path_outside_a_repository() {
let h = spawn().await;
let tmp = tempfile::tempdir().unwrap();
let plain = tmp.path().join("plain");
std::fs::create_dir_all(&plain).unwrap();
let branches = get_branches(h.addr, &plain).await;
assert!(branches.is_empty());
}
+59 -11
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};
@@ -92,6 +93,38 @@ fn run(cmd: &mut Command) {
);
}
/// An empty bare repository at `dir`, usable as a push target (#79).
pub fn init_bare_repo(dir: &Path) {
run(Command::new("git")
.arg("init")
.arg("-q")
.arg("--bare")
.arg(dir.to_str().expect("non-utf8 bare repo path")));
}
/// Push every branch of `repo` to `origin`, creating remote-tracking refs.
///
/// The unsaved-work probe (#79) counts commits reachable from no
/// remote-tracking ref as unpushed, so tests that want a killable worktree
/// must give its branch one. Safe to call again: re-adding an existing
/// remote is ignored.
pub fn push_all_to_origin(repo: &Path, origin: &Path) {
let _ = Command::new("git")
.arg("-C")
.arg(repo)
.args([
"remote",
"add",
"origin",
origin.to_str().expect("non-utf8 origin path"),
])
.status();
run(Command::new("git")
.arg("-C")
.arg(repo)
.args(["push", "-q", "--all", "origin"]));
}
/// Async polling sibling of [`wait_for`] for tokio tests.
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
where
@@ -114,29 +147,27 @@ pub struct DaemonFixture {
pub tmux: TmuxFixture,
pub manager: SessionManager,
pub state_dir: TempDir,
pub worktree_root: TempDir,
}
impl DaemonFixture {
pub fn new(daemon_id: &str) -> Self {
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());
// Default placement: worktrees land under their base repo (#56).
let worktree_mgr = WorktreeManager::new(None);
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"),
@@ -145,11 +176,15 @@ impl DaemonFixture {
manager =
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
}
// Same wiring as production: the manager records retained worktrees
// into the same dir the API reads back from (#78/#81).
let retained =
maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir));
manager = manager.with_retained_store(retained.clone());
Self {
tmux,
manager,
state_dir,
worktree_root,
}
}
@@ -157,6 +192,10 @@ impl DaemonFixture {
state_dir.path().join("spawn-history.jsonl")
}
fn retained_path(state_dir: &TempDir) -> PathBuf {
state_dir.path().join("retained-worktrees")
}
pub fn spawn_history_path(&self) -> PathBuf {
Self::history_path(&self.state_dir)
}
@@ -165,6 +204,15 @@ impl DaemonFixture {
self.state_dir.path().join("sessions")
}
/// Retained-worktree store rooted at the fixture's own state dir (#78).
pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore {
maestro_daemon::retained_store::RetainedStore::new(self.retained_dir())
}
pub fn retained_dir(&self) -> PathBuf {
self.state_dir.path().join("retained-worktrees")
}
pub fn fifo_dir(&self) -> PathBuf {
self.state_dir.path().join("fifos")
}
+613 -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::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -24,42 +21,42 @@ type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
struct Harness {
addr: SocketAddr,
manager: maestro_daemon::SessionManager,
catalog: CatalogMirror,
_shutdown: oneshot::Sender<()>,
_fx: DaemonFixture,
}
async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
let fx = DaemonFixture::new(daemon_id);
impl Harness {
/// Store over the same dir the manager records into.
fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore {
self._fx.retained_store()
}
}
async fn spawn(hostname: &str) -> Harness {
spawn_with(
hostname,
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
)
.await
}
async fn spawn_with(
hostname: &str,
projects: std::sync::Arc<maestro_daemon::projects::ProjectScanner>,
) -> Harness {
let fx = DaemonFixture::new();
let manager = fx.manager.clone();
let catalog = CatalogMirror::new();
let events = LocalEventBus::new();
// 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));
projects,
std::sync::Arc::new(fx.retained_store()),
));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
@@ -75,7 +72,6 @@ async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness {
Harness {
addr,
manager,
catalog,
_shutdown: tx,
_fx: fx,
}
@@ -85,100 +81,27 @@ fn url(addr: SocketAddr, path: &str) -> String {
format!("http://{addr}{path}")
}
fn ws_url(addr: SocketAddr, path: &str) -> String {
format!("ws://{addr}{path}")
}
#[tokio::test]
async fn web_info_returns_daemon_identity() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/web/info"))
async fn info_returns_daemon_identity() {
let h = spawn("naps-laptop").await;
let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["daemon_id"], "laptop");
assert_eq!(resp["hostname"], "naps-laptop");
assert_eq!(resp["daemon_url"], "ws://10.0.0.1:7070");
assert_eq!(resp["home_dir"], "/home/test");
assert!(resp["version"].is_string());
}
#[tokio::test]
async fn list_daemons_includes_self_and_mirrored_peers() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
// Before any catalog events arrive, the daemon only knows about itself.
let list: Vec<DaemonInfo> = reqwest::get(url(h.addr, "/api/daemons"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].daemon_id, "laptop");
// Simulate the registry fanning out a peer addition.
h.catalog
.apply(RegistryEvent::DaemonAdded {
daemon: DaemonInfo {
daemon_id: "konishi".into(),
hostname: "konishi-host".into(),
version: "0.1.0".into(),
daemon_url: "ws://10.0.0.2:7070".into(),
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 +112,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
assert!(empty.is_empty());
let req = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: h.manager.daemon_id().into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
@@ -205,7 +127,6 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
.json()
.await
.unwrap();
assert_eq!(created.daemon_id, "laptop");
let after: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
.await
@@ -218,37 +139,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 +159,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()
@@ -278,16 +173,134 @@ async fn recent_dirs_self_returns_empty_without_history() {
assert_eq!(resp["truncated"], false);
}
/// #54: `GET /api/projects` lists every git repo under the scan roots, with
/// real git checkouts — not mocks.
#[tokio::test]
async fn 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 projects_lists_repos_under_the_scan_roots() {
let tmp = tempfile::tempdir().unwrap();
for name in ["alpha", "beta"] {
let repo = tmp.path().join(name);
std::fs::create_dir_all(&repo).unwrap();
for args in [
vec!["init", "-q"],
vec![
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-qm",
"init",
"--allow-empty",
],
] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(&repo)
.args(&args)
.output()
.unwrap();
assert!(out.status.success());
}
}
let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new(
vec![tmp.path().to_path_buf()],
3,
tmp.path().to_path_buf(),
));
let h = spawn_with("naps-laptop", scanner).await;
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
.await
.unwrap()
.json()
.await
.unwrap();
let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
names.sort();
assert_eq!(names, ["alpha", "beta"]);
for p in &projects {
assert!(!p.is_worktree);
assert_eq!(p.base_repo, None);
assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known");
}
}
#[tokio::test]
async fn projects_returns_empty_for_a_disabled_scanner() {
let h = spawn("naps-laptop").await;
let projects: Vec<maestro_protocol::Project> = reqwest::get(url(h.addr, "/api/projects"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(projects.is_empty());
}
/// #53: every returned handle splits into a known adjective and noun.
fn assert_valid_names(batch: &maestro_protocol::NameBatch) {
for name in &batch.names {
let (adj, noun) = name.split_once('-').unwrap_or_else(|| {
panic!("handle {name:?} is not adjective-noun");
});
assert!(
maestro_names::ADJECTIVES.contains(&adj),
"{adj} not in ADJECTIVES"
);
assert!(maestro_names::NOUNS.contains(&noun), "{noun} not in NOUNS");
}
}
#[tokio::test]
async fn names_defaults_to_one_handle() {
let h = spawn("naps-laptop").await;
let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(batch.names.len(), 1);
assert_valid_names(&batch);
}
#[tokio::test]
async fn names_honors_count_and_caps_it() {
use maestro_names::MAX_BATCH;
let h = spawn("naps-laptop").await;
let batch: maestro_protocol::NameBatch = reqwest::get(url(h.addr, "/api/names?count=5"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(batch.names.len(), 5);
assert_valid_names(&batch);
let batch: maestro_protocol::NameBatch =
reqwest::get(url(h.addr, &format!("/api/names?count={}", MAX_BATCH * 10)))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(batch.names.len(), MAX_BATCH);
}
#[tokio::test]
async fn gitroot_probes_path() {
// #26: resolves the query against the daemon home dir and walks up for a
// `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null.
let h = spawn("naps-laptop").await;
let tmp = tempfile::tempdir().unwrap();
let resp: serde_json::Value = reqwest::get(url(
h.addr,
&format!(
"/api/daemons/laptop/gitroot?path={}",
"/api/gitroot?path={}",
urlencoding::encode(&tmp.path().to_string_lossy())
),
))
@@ -299,22 +312,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 +325,10 @@ async fn delete_unknown_session_returns_404() {
#[tokio::test]
async fn delete_owned_session_kills_it() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -347,13 +346,113 @@ async fn delete_owned_session_kills_it() {
assert!(!h.manager.has(session.id).await);
}
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
/// with the structured breakdown, and the session keeps running.
#[tokio::test]
async fn broker_ws_attaches_to_local_session() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() {
let h = spawn("naps-laptop").await;
let repo = tempfile::TempDir::new().unwrap();
common::init_repo(repo.path());
let session = h
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
create_branch: true,
}),
})
.await
.unwrap();
std::fs::write(
repo.path()
.join("worktrees")
.join("feat")
.join("guarded")
.join("README"),
b"unsaved edits\n",
)
.unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
let body: ApiError = resp.json().await.unwrap();
assert_eq!(body.error.code, "kill_guarded");
let breakdown = body.error.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.uncommitted_files, 1);
assert!(h.manager.has(session.id).await, "the session keeps running");
}
/// `?force=true` on the wire (#81): the refusal is overridden, the session
/// ends, and the answer is 200 with the retained worktree named — path,
/// branch, reason — while the directory survives on disk.
#[tokio::test]
async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
let h = spawn("naps-laptop").await;
let repo = tempfile::TempDir::new().unwrap();
common::init_repo(repo.path());
let wt_path = repo
.path()
.join("worktrees")
.join("feat")
.join("guarded")
.to_path_buf();
let session = h
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
create_branch: true,
}),
})
.await
.unwrap();
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
.query(&[("force", "true")])
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK);
let body: maestro_protocol::KillOutcome = resp.json().await.unwrap();
let retained = body.retained.expect("force names what was kept");
assert_eq!(retained.path, wt_path);
assert_eq!(retained.branch, "feat/guarded");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
// The tree would have blocked; force must not have removed it.
assert!(wt_path.exists(), "a forced kill never removes the worktree");
// And the store entry exists, so reclaim can find it (#80).
let all = h.fx_retained().load_all().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, retained.id);
}
#[tokio::test]
async fn attach_ws_streams_pty_bytes() {
let h = spawn("naps-laptop").await;
let session = h
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: "/tmp".into(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -367,7 +466,7 @@ async fn broker_ws_attaches_to_local_session() {
let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap();
// Provoke output. The bash process should echo this back via the PTY.
ws.send(WsMessage::Binary(b"echo hello-broker\n".to_vec()))
ws.send(WsMessage::Binary(b"echo hello-attach\n".to_vec()))
.await
.unwrap();
@@ -375,7 +474,7 @@ async fn broker_ws_attaches_to_local_session() {
loop {
match ws.next().await {
Some(Ok(WsMessage::Binary(bytes))) => {
if String::from_utf8_lossy(&bytes).contains("hello-broker") {
if String::from_utf8_lossy(&bytes).contains("hello-attach") {
return true;
}
}
@@ -386,12 +485,12 @@ async fn broker_ws_attaches_to_local_session() {
})
.await
.unwrap_or(false);
assert!(saw_hello, "PTY did not echo through the broker WS");
assert!(saw_hello, "PTY did not echo through the attach WS");
}
#[tokio::test]
async fn broker_ws_for_unknown_session_returns_404_not_502() {
let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await;
async fn attach_ws_for_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
let ws_url = format!("ws://{}/api/attach/{}", h.addr, Uuid::new_v4());
let err = connect_async(ws_url)
.await
@@ -402,3 +501,334 @@ async fn broker_ws_for_unknown_session_returns_404_not_502() {
"{msg}"
);
}
/// `WS /api/events` had no daemon-level test: the Snapshot-then-deltas contract
/// and the subscribe-before-snapshot ordering were only described in a comment.
#[tokio::test]
async fn events_ws_sends_a_snapshot_then_streams_deltas() {
let h = spawn("konishi").await;
let cwd = tempfile::TempDir::new().unwrap();
let (mut ws, _) = connect_async(ws_url(h.addr, "/api/events"))
.await
.expect("events ws");
match next_event(&mut ws).await {
maestro_protocol::CatalogEvent::Snapshot { sessions } => assert!(sessions.is_empty()),
other => panic!("first frame must be a Snapshot, got {other:?}"),
}
let created = h
.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
match next_event(&mut ws).await {
maestro_protocol::CatalogEvent::SessionAdded { session } => {
assert_eq!(session.id, created.id);
}
other => panic!("expected SessionAdded, got {other:?}"),
}
h.manager.kill(created.id, false).await.expect("kill");
// Activity ticks may interleave; the removal is what we are waiting for.
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
assert!(
tokio::time::Instant::now() < deadline,
"timed out waiting for SessionRemoved"
);
if let maestro_protocol::CatalogEvent::SessionRemoved { id } = next_event(&mut ws).await {
assert_eq!(id, created.id);
break;
}
}
}
/// Each subscriber has its own lag domain now that the publisher task is gone:
/// two clients connected at different times both get their own Snapshot.
#[tokio::test]
async fn every_events_subscriber_gets_its_own_snapshot() {
let h = spawn("konishi").await;
let cwd = tempfile::TempDir::new().unwrap();
let (mut first, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap();
let _ = next_event(&mut first).await;
h.manager
.create(CreateSessionRequest {
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("create");
let (mut second, _) = connect_async(ws_url(h.addr, "/api/events")).await.unwrap();
match next_event(&mut second).await {
maestro_protocol::CatalogEvent::Snapshot { sessions } => {
assert_eq!(
sessions.len(),
1,
"a late subscriber must see current state"
);
}
other => panic!("expected Snapshot, got {other:?}"),
}
}
async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
loop {
match tokio::time::timeout(Duration::from_secs(5), ws.next())
.await
.expect("events ws timed out")
{
Some(Ok(WsMessage::Text(t))) => {
return serde_json::from_str(&t).expect("decode catalog event")
}
Some(Ok(_)) => continue,
other => panic!("unexpected events frame: {other:?}"),
}
}
}
// ---------------------------------------------------------------------------
// GET /api/worktrees + DELETE /api/worktrees/:id (#80)
// ---------------------------------------------------------------------------
/// `git -C dir args`, asserting success — the worktree endpoints are tested
/// against real git, like everything else in this crate.
fn git(dir: &std::path::Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
struct RetainedFixture {
repo: tempfile::TempDir,
wt: std::path::PathBuf,
}
/// A real repo with a linked worktree for branch `feat/login` whose tree holds
/// an uncommitted tracked edit and an unpushed commit.
fn retained_fixture() -> RetainedFixture {
let repo = tempfile::tempdir().unwrap();
common::init_repo(repo.path());
// A pushed main, so "unpushed" means commits made on the feature branch,
// not the shared history.
let remote = repo.path().join("remote.git");
std::fs::create_dir_all(&remote).unwrap();
let remote_s = remote.to_string_lossy().into_owned();
git(&remote, &["init", "-q", "--bare"]);
git(repo.path(), &["remote", "add", "origin", &remote_s]);
git(repo.path(), &["push", "-q", "-u", "origin", "main"]);
git(
repo.path(),
&[
"worktree",
"add",
"-q",
"-b",
"feat/login",
"worktrees/feat/login",
],
);
let wt = repo.path().join("worktrees").join("feat").join("login");
RetainedFixture { repo, wt }
}
impl RetainedFixture {
fn commit_in_wt(&self, msg: &str) {
std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap();
git(&self.wt, &["add", "-A"]);
git(&self.wt, &["commit", "-qm", msg]);
}
fn branch_sha(&self, branch: &str) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(self.repo.path())
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.unwrap();
assert!(out.status.success(), "branch {branch} must exist");
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
}
#[tokio::test]
async fn worktrees_lists_retained_entries_with_recomputed_breakdown() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
// Uncommitted tracked edit + an untracked file; the branch has no commits
// beyond main yet.
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap();
std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap();
let store = h._fx.retained_store();
let entry = store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ForceKilled,
)
.unwrap();
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(listed.len(), 1);
let w = &listed[0];
assert_eq!(w.id, entry.id);
assert_eq!(w.path, fx.wt);
assert_eq!(w.base_repo, fx.repo.path());
assert_eq!(w.branch, "feat/login");
assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled);
assert_eq!(w.in_the_way.uncommitted_files, 1);
assert_eq!(w.in_the_way.untracked_files, 1);
assert_eq!(w.in_the_way.unpushed_commits, 0);
// The operator saves the work themselves. The stored entry is untouched —
// the next read must report the tree as it is *now*, not as it was when
// it was retained.
fx.commit_in_wt("saved");
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(listed.len(), 1, "same entry, still retained");
assert_eq!(listed[0].id, entry.id);
assert_eq!(listed[0].in_the_way.uncommitted_files, 0);
assert_eq!(listed[0].in_the_way.unpushed_commits, 1);
}
#[tokio::test]
async fn reclaim_removes_directory_and_entry_but_never_the_branch() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
fx.commit_in_wt("precious");
let sha = fx.branch_sha("feat/login");
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse
let store = h._fx.retained_store();
let entry = store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork,
)
.unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
assert!(!fx.wt.exists(), "reclaim removes the directory");
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(listed.is_empty(), "reclaim drops the store entry");
assert_eq!(
fx.branch_sha("feat/login"),
sha,
"the branch and its commits survive"
);
}
#[tokio::test]
async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
let store = h._fx.retained_store();
let entry = store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ForceKilled,
)
.unwrap();
std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
assert!(store.load_all().unwrap().is_empty());
}
#[tokio::test]
async fn reclaim_unknown_id_returns_404() {
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4())))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() {
let h = spawn("naps-laptop").await;
let fx = retained_fixture();
let store = h._fx.retained_store();
store
.retain(
&fx.wt,
fx.repo.path(),
"feat/login",
maestro_daemon::retained_store::RetainedReason::ForceKilled,
)
.unwrap();
std::fs::remove_dir_all(&fx.wt).unwrap();
let listed: Vec<maestro_protocol::RetainedWorktree> =
reqwest::get(url(h.addr, "/api/worktrees"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(listed.is_empty(), "a hand-removed directory is not listed");
assert!(
store.load_all().unwrap().is_empty(),
"and its entry is gone"
);
}
@@ -1,214 +0,0 @@
//! End-to-end tests for the daemon's outbound registry client.
//!
//! Uses the `maestro-registry` crate as the real registry under test. Verifies
//! the daemon connects, sends Hello + snapshot, forwards diffs, replies to
//! inbound CreateSession/KillSession, and reconnects after a registry kill.
mod common;
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use maestro_daemon::RegistryClient;
use maestro_protocol::CreateSessionRequest;
use maestro_registry::{router, Registry};
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
const TOKEN: &str = "shared-registry-token";
struct RegistryHarness {
url: String,
state: Registry,
shutdown: Option<oneshot::Sender<()>>,
}
async fn spawn_registry() -> RegistryHarness {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let state = Registry::new();
let app = router(state.clone(), TOKEN.to_string());
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
RegistryHarness {
url: format!("ws://{addr}/daemon"),
state,
shutdown: Some(shutdown_tx),
}
}
#[tokio::test]
async fn daemon_registers_and_streams_snapshot() {
let reg = spawn_registry().await;
let fx = DaemonFixture::new("laptop");
let client = RegistryClient::new(
reg.url.clone(),
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
tokio::spawn(async move { client.run().await });
// wait for the Hello + snapshot to land
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move {
let daemons = r.daemons().await;
daemons.len() == 1 && daemons[0].daemon_id == "laptop"
}
})
.await;
// create a session via the manager — should trigger a SessionAdded diff
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap();
let state = reg.state.clone();
let want_id = session.id;
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move {
let s = r.sessions().await;
s.len() == 1 && s[0].id == want_id
}
})
.await;
let _ = fx.manager.kill(session.id).await;
}
#[tokio::test]
async fn daemon_reconnects_after_registry_drop() {
// first registry: accept connection, then drop
let mut reg1 = spawn_registry().await;
let url = reg1.url.clone();
let fx = DaemonFixture::new("laptop");
let client = RegistryClient::new(
url.clone(),
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
tokio::spawn(async move { client.run().await });
// wait for daemon to register on the first registry
let state1 = reg1.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state1.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
// drop registry1; daemon should reconnect once we bring another up on the
// same port. Easier: bring up a second registry on a *different* port
// requires changing url, which RegistryClient doesn't support — instead,
// just verify the daemon's task survives the drop and the original
// registry's state becomes empty (the daemon connection died).
let _ = reg1.shutdown.take().unwrap().send(());
// give some time for the daemon to notice
tokio::time::sleep(Duration::from_millis(500)).await;
// RegistryClient::run loops forever, so we can't easily assert
// reconnection without standing up the registry on the same port again.
// The hard part — graceful failure of the inner connection — is what we're
// really testing: the daemon must not panic.
// (A full reconnect-against-fresh-server test belongs in the e2e crate
// NAP-217.)
let _ = fx;
}
#[tokio::test]
async fn registry_create_session_round_trips() {
let reg = spawn_registry().await;
let fx = DaemonFixture::new("laptop");
let client = RegistryClient::new(
reg.url.clone(),
"laptop",
"naps-laptop",
"ws://127.0.0.1:8081",
"/home/naps62",
TOKEN,
fx.manager.clone(),
);
tokio::spawn(async move { client.run().await });
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move { !r.daemons().await.is_empty() }
})
.await;
let cwd = TempDir::new().unwrap();
let request = CreateSessionRequest {
daemon_id: "laptop".into(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
};
let result = reg
.state
.create_session("laptop", request)
.await
.expect("transport ok");
let session = result.expect("rpc ok");
assert_eq!(session.command, "sleep 30");
// the registry should also see the SessionAdded diff in its catalog
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move { r.sessions().await.len() == 1 }
})
.await;
// and KillSession should round-trip too
let kill = reg
.state
.kill_session("laptop", session.id)
.await
.expect("transport ok");
kill.expect("rpc ok");
let state = reg.state.clone();
wait_for_async(Duration::from_secs(5), || {
let r = state.clone();
async move { r.sessions().await.is_empty() }
})
.await;
}
@@ -0,0 +1,398 @@
//! The session manager's decisions, driven through [`FakePanes`].
//!
//! These are the branches that used to need a forked tmux server to reach:
//! spawn rollback ordering, liveness diffing, and activity change-detection.
//! Nothing here spawns a process, so they run everywhere and in milliseconds.
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use maestro_daemon::panes::{Call, FakePanes, Op};
use maestro_daemon::session::{SessionError, SessionManager};
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::tmux::PaneInfo;
use maestro_daemon::worktree::WorktreeManager;
use maestro_protocol::{ActivityState, CatalogEvent, CreateSessionRequest, SessionStatus};
use tempfile::TempDir;
use tokio::sync::broadcast::error::TryRecvError;
use uuid::Uuid;
struct Fx {
manager: SessionManager,
fake: Arc<FakePanes>,
tmp: TempDir,
}
impl Fx {
fn new() -> Self {
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
}
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
let tmp = TempDir::new().expect("tempdir");
let fake = Arc::new(FakePanes::new());
let fifo_dir = tmp.path().join("fifos");
std::fs::create_dir_all(&fifo_dir).expect("fifo dir");
let manager = SessionManager::new(
fake.clone(),
SidecarStore::new(pick(tmp.path())),
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
fifo_dir,
);
Self { manager, fake, tmp }
}
fn request(&self, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
cwd: self.tmp.path().to_path_buf(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
}
}
}
/// Poll until `id` reaches `want`.
async fn wait_for_status(manager: &SessionManager, id: uuid::Uuid, want: SessionStatus) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
if manager.status(id).await == Some(want) {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("timed out waiting for {id} to become {want:?}");
}
/// NAP-260 rollback, asserted as an *ordering* rather than an end state.
///
/// Replaces a test that forced the sidecar failure with `chmod 0o500` and then
/// returned early when it detected root — so on CI it asserted nothing. Here
/// the sidecar directory cannot be created because a regular file sits where
/// its parent must be, which no uid can work around.
#[tokio::test]
async fn create_kills_the_tmux_session_when_the_sidecar_write_fails() {
let fx = Fx::with_sidecar_dir(|tmp| {
let blocker = tmp.join("blocker");
std::fs::write(&blocker, b"not a directory").expect("blocker file");
blocker.join("sidecars")
});
let res = fx.manager.create(fx.request("sleep 30")).await;
assert!(
matches!(res, Err(SessionError::Sidecar(_))),
"expected the sidecar write to fail, got {res:?}"
);
let calls = fx.fake.calls();
assert_eq!(
calls.len(),
2,
"expected spawn then rollback kill, got {calls:?}"
);
assert!(matches!(calls[0], Call::NewSessionPiped { .. }));
let Call::NewSessionPiped { id, .. } = calls[0] else {
unreachable!()
};
assert_eq!(
calls[1],
Call::KillSession(id),
"the rollback must kill the session it just spawned"
);
assert!(!fx.fake.is_live(id), "create left a ghost tmux session");
assert!(fx.manager.snapshot().await.is_empty());
}
/// A failed spawn must not roll back anything, least of all kill a session id
/// tmux never accepted.
#[tokio::test]
async fn create_does_not_roll_back_when_the_spawn_itself_fails() {
let fx = Fx::new();
fx.fake.fail(Op::NewSessionPiped, "no server running");
let res = fx.manager.create(fx.request("sleep 30")).await;
assert!(res.is_err(), "expected the spawn to fail, got {res:?}");
assert_eq!(
fx.fake.calls().len(),
1,
"only the failed spawn should have been attempted: {:?}",
fx.fake.calls()
);
assert!(fx.manager.snapshot().await.is_empty());
}
/// A restarted daemon adopts whatever tmux still holds, and must re-pipe each
/// one — without that the adopted session produces no output.
#[tokio::test]
async fn recover_adopts_live_sessions_and_repipes_them() {
let fx = Fx::new();
let orphan = Uuid::new_v4();
fx.fake.add_live(orphan);
let adopted = fx.manager.recover().await.expect("recover");
assert_eq!(adopted, 1);
let snap = fx.manager.snapshot().await;
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].id, orphan);
assert!(
fx.fake.calls().contains(&Call::PipePane(orphan)),
"adopted session was never re-piped: {:?}",
fx.fake.calls()
);
}
/// A session tmux lost is marked `Dead` and kept, so the operator can still see
/// it exited and read what it printed. It is not removed.
#[tokio::test]
async fn liveness_marks_the_lost_session_dead_and_keeps_it() {
let fx = Fx::new();
let keep = fx.manager.create(fx.request("keep")).await.expect("keep");
let lose = fx.manager.create(fx.request("lose")).await.expect("lose");
let mut events = fx.manager.events();
fx.fake.remove_live(lose.id);
fx.manager.start_liveness_watcher(Duration::from_millis(25));
wait_for_status(&fx.manager, lose.id, SessionStatus::Dead).await;
let all = fx.manager.snapshot().await;
assert_eq!(all.len(), 2, "the dead session must stay in the catalog");
let survivor = all.iter().find(|s| s.id == keep.id).expect("keep");
assert_eq!(survivor.status, SessionStatus::Running);
let mut removed = Vec::new();
while let Ok(ev) = events.try_recv() {
if let CatalogEvent::SessionRemoved { id } = ev {
removed.push(id);
}
}
assert!(removed.is_empty(), "nothing should be removed: {removed:?}");
}
/// The transition fires once. Without the Running filter the pass re-announces
/// the same dead session on every tick, forever.
#[tokio::test]
async fn a_dead_session_is_announced_only_once() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
let mut events = fx.manager.events();
fx.fake.remove_live(s.id);
fx.manager.start_liveness_watcher(Duration::from_millis(25));
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
let mut dead_updates = 0;
let deadline = tokio::time::Instant::now() + Duration::from_millis(300);
while tokio::time::Instant::now() < deadline {
if let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
if session.status == SessionStatus::Dead {
dead_updates += 1;
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(dead_updates, 1, "expected exactly one Dead announcement");
}
/// Dismissing a dead session releases it — without asking tmux to kill
/// something tmux already lost.
#[tokio::test]
async fn killing_a_dead_session_dismisses_it_without_touching_tmux() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
fx.fake.remove_live(s.id);
fx.manager.start_liveness_watcher(Duration::from_millis(25));
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
fx.manager.kill(s.id, false).await.expect("dismiss");
assert!(fx.manager.snapshot().await.is_empty());
assert!(
!fx.fake
.calls()
.iter()
.any(|c| matches!(c, Call::KillSession(k) if *k == s.id)),
"kill-session was called on a session tmux had already lost: {:?}",
fx.fake.calls()
);
}
/// A dead session has no pane, so input is dropped rather than surfacing as a
/// tmux failure the client would have to interpret.
#[tokio::test]
async fn a_dead_session_is_read_only() {
let fx = Fx::new();
let s = fx.manager.create(fx.request("bash")).await.expect("create");
fx.fake.remove_live(s.id);
fx.manager.start_liveness_watcher(Duration::from_millis(25));
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
fx.manager
.send_keys(s.id, b"ls\n")
.await
.expect("send_keys");
fx.manager.resize(s.id, 80, 24).await.expect("resize");
assert!(
!fx.fake
.calls()
.iter()
.any(|c| matches!(c, Call::SendKeys { .. } | Call::ResizeWindow { .. })),
"input reached tmux for a dead session: {:?}",
fx.fake.calls()
);
}
/// `CreateSessionRequest.env` was accepted, stored, echoed back to clients, and
/// then dropped: `new_session_piped` had no env parameter at all.
#[tokio::test]
async fn requested_env_reaches_tmux() {
let fx = Fx::new();
let mut req = fx.request("bash");
req.env.insert("FOO".into(), "bar".into());
req.env.insert("EMPTY".into(), String::new());
fx.manager.create(req).await.expect("create");
let want: BTreeMap<String, String> = [
("EMPTY".to_string(), String::new()),
("FOO".to_string(), "bar".to_string()),
]
.into_iter()
.collect();
match fx.fake.calls().first() {
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
other => panic!("expected a spawn call, got {other:?}"),
}
}
#[tokio::test]
async fn an_env_key_tmux_would_misparse_is_rejected() {
let fx = Fx::new();
let mut req = fx.request("bash");
req.env.insert("A=B".into(), "x".into());
let err = fx.manager.create(req).await.expect_err("should reject");
assert_eq!(err.to_error_info().code, "invalid_env");
assert!(
fx.fake.calls().is_empty(),
"nothing should have been spawned: {:?}",
fx.fake.calls()
);
}
/// If `list_sessions` fails the pass has no idea what is alive. Reaping on that
/// basis would kill the whole catalog.
#[tokio::test]
async fn liveness_reaps_nothing_when_tmux_cannot_be_listed() {
let fx = Fx::new();
let alive = fx.manager.create(fx.request("bash")).await.expect("create");
fx.fake.fail(Op::ListSessions, "connection refused");
fx.manager.start_liveness_watcher(Duration::from_millis(25));
// Give the watcher several passes to do the wrong thing.
tokio::time::sleep(Duration::from_millis(200)).await;
let snap = fx.manager.snapshot().await;
assert_eq!(
snap.len(),
1,
"a failed list must not be read as 'everything died'"
);
assert_eq!(snap[0].id, alive.id);
}
#[tokio::test]
async fn activity_pass_emits_updated_only_when_something_changed() {
let fx = Fx::new();
let session = fx.manager.create(fx.request("zsh")).await.expect("create");
let mut events = fx.manager.events();
fx.fake.set_pane_info(
session.id,
PaneInfo {
// A pid nothing can be reading from, so `awaiting_input` is a
// deterministic false rather than a probe of this machine.
pane_pid: i32::MAX,
command: "claude".into(),
tty: "/dev/null".into(),
current_path: "/srv/work".into(),
},
);
fx.manager.start_liveness_watcher(Duration::from_millis(25));
let updated = wait_for_updated(&mut events).await;
assert_eq!(updated.foreground.as_deref(), Some("claude"));
assert_eq!(
updated.current_cwd.as_deref(),
Some(std::path::Path::new("/srv/work"))
);
// Still inside `activity::ACTIVE_WINDOW` (3s) since the spawn, so the
// change here is `foreground`, not the activity state.
assert_eq!(updated.activity, ActivityState::Active);
// Drain anything already queued, then confirm a steady pane stays quiet.
// Stays well inside the 3s window, so no Active -> Idle transition either.
while events.try_recv().is_ok() {}
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(
matches!(events.try_recv(), Err(TryRecvError::Empty)),
"an unchanged pane must not re-emit SessionUpdated every tick"
);
}
async fn wait_for_updated(
events: &mut tokio::sync::broadcast::Receiver<CatalogEvent>,
) -> maestro_protocol::Session {
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
match events.try_recv() {
Ok(CatalogEvent::SessionUpdated { session }) => return session,
Ok(_) => continue,
Err(_) => tokio::time::sleep(Duration::from_millis(10)).await,
}
}
panic!("timed out waiting for SessionUpdated");
}
/// The kill handler used to check `has()` first, which opened a window for the
/// liveness watcher to reap the session in between — turning a 404 into a 502.
/// `kill` reports the miss itself now.
#[tokio::test]
async fn killing_an_unknown_session_is_reported_as_not_found() {
let fx = Fx::new();
let err = fx
.manager
.kill(uuid::Uuid::new_v4(), false)
.await
.expect_err("should fail");
assert_eq!(err.to_error_info().code, "not_found");
}
/// A construction-order mistake must be loud: a silently-ignored history leaves
/// the recent-dirs picker permanently empty.
#[test]
#[should_panic(expected = "before the SessionManager is cloned")]
fn attaching_spawn_history_after_cloning_panics() {
let tmp = TempDir::new().expect("tempdir");
let fake = Arc::new(FakePanes::new());
let manager = SessionManager::new(
fake,
SidecarStore::new(tmp.path().join("sidecars")),
WorktreeManager::new(Some(tmp.path().join("worktrees"))),
tmp.path().join("fifos"),
);
let _clone = manager.clone();
let _ = manager.with_spawn_history(maestro_daemon::spawn_history::SpawnHistory::new(
tmp.path().join("history.jsonl"),
7,
));
}
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -12,14 +12,14 @@ use serde_json::Value;
use tempfile::TempDir;
use uuid::Uuid;
fn sample(id: Uuid, daemon_id: &str) -> Session {
fn sample(id: Uuid) -> Session {
Session {
id,
daemon_id: daemon_id.to_string(),
cwd: PathBuf::from("/tmp/work"),
command: "bash".into(),
env: HashMap::from([("FOO".into(), "bar".into())]),
metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]),
worktree: None,
status: SessionStatus::Running,
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
.unwrap()
@@ -28,6 +28,7 @@ fn sample(id: Uuid, daemon_id: &str) -> Session {
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None,
}
}
@@ -36,7 +37,7 @@ fn write_then_read_round_trip() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let s = sample(id, "laptop");
let s = sample(id);
store.write(&s).expect("write");
let loaded = store.read(id).expect("read");
@@ -59,7 +60,7 @@ fn delete_removes_file_and_is_idempotent() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let s = sample(id, "laptop");
let s = sample(id);
store.write(&s).unwrap();
assert!(store.path_for(id).exists());
@@ -76,8 +77,8 @@ fn load_all_recovers_every_sidecar_and_skips_garbage() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let s1 = sample(Uuid::new_v4(), "laptop");
let s2 = sample(Uuid::new_v4(), "dev-vm");
let s1 = sample(Uuid::new_v4());
let s2 = sample(Uuid::new_v4());
store.write(&s1).unwrap();
store.write(&s2).unwrap();
@@ -86,9 +87,9 @@ fn load_all_recovers_every_sidecar_and_skips_garbage() {
std::fs::write(dir.path().join("broken.json"), b"{not json}").unwrap();
let mut loaded = store.load_all().expect("load_all");
loaded.sort_by_key(|s| s.daemon_id.clone());
loaded.sort_by_key(|s| s.id);
let mut expected = vec![s1, s2];
expected.sort_by_key(|s| s.daemon_id.clone());
expected.sort_by_key(|s| s.id);
assert_eq!(loaded, expected);
}
@@ -104,7 +105,7 @@ fn write_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let nested = dir.path().join("a/b/c");
let store = SidecarStore::new(&nested);
let s = sample(Uuid::new_v4(), "laptop");
let s = sample(Uuid::new_v4());
store.write(&s).expect("write");
assert!(nested.exists());
assert!(store.path_for(s.id).exists());
+479 -10
View File
@@ -6,8 +6,9 @@ mod common;
use std::path::PathBuf;
use std::process::Command;
use common::init_repo;
use common::{init_bare_repo, init_repo, push_all_to_origin};
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::worktree_probe::in_the_way;
use tempfile::TempDir;
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
@@ -31,9 +32,9 @@ fn add_creates_worktree_on_new_branch() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add(&repo, "feat/example").expect("add worktree");
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert!(path.exists());
assert!(path.join("README").exists());
assert!(path.starts_with(wt_root.path()));
@@ -42,6 +43,82 @@ fn add_creates_worktree_on_new_branch() {
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
}
#[test]
fn add_places_worktree_inside_the_base_repo_by_default() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// No override: the worktree must land where the repository keeps its
// own worktrees (#56), not under some daemon state dir.
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
assert_eq!(path, repo.join("worktrees").join("feat").join("example"));
assert!(path.exists());
assert!(path.join("README").exists());
let branches = worktree_branches(&repo);
assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}");
}
#[test]
fn worktree_root_override_redirects_placement() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
// The repo's basename sits between the root and the branch slug, so two
// repos spawning the same branch name don't target one directory.
let repo_name = repo.file_name().unwrap();
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
// The override root disambiguates same-named checkouts with a digest of
// the full repo path, and the branch keeps its own shape.
// <root>/<repo basename>-<digest>/feat/example
let prefix = format!("{}-", repo_name.to_string_lossy());
let repo_dir_name = path
.strip_prefix(wt_root.path())
.unwrap()
.components()
.next()
.unwrap();
assert!(
repo_dir_name
.as_os_str()
.to_string_lossy()
.starts_with(&prefix),
"{path:?}"
);
assert!(path.ends_with("feat/example"), "{path:?}");
assert!(path.exists());
}
/// Anything at all occupying the target path — ours, the operator's, or an
/// unrelated directory — is sidestepped, not adopted and not reported (#77).
#[test]
fn an_occupied_target_path_gets_a_numeric_suffix() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// A non-git directory sits exactly where the worktree would go.
let squatter = repo.join("worktrees").join("feat").join("collide");
std::fs::create_dir_all(&squatter).unwrap();
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
assert_eq!(path, repo.join("worktrees").join("feat").join("collide-2"));
assert!(path.exists());
assert!(
squatter.join("operator-notes.txt").exists(),
"the occupant is untouched"
);
}
#[test]
fn add_twice_generates_distinct_paths() {
let repo_dir = TempDir::new().unwrap();
@@ -49,15 +126,132 @@ fn add_twice_generates_distinct_paths() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let p1 = mgr.add(&repo, "feat/a").expect("add a");
let p2 = mgr.add(&repo, "feat/b").expect("add b");
let p1 = mgr.add_new(&repo, "feat/a").expect("add a");
let p2 = mgr.add_new(&repo, "feat/b").expect("add b");
assert_ne!(p1, p2);
assert!(p1.exists());
assert!(p2.exists());
}
/// The unsaved-work probe (#79) counts uncommitted tracked changes.
#[test]
fn the_probe_counts_uncommitted_tracked_changes() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1);
assert_eq!(w.untracked_files, 0);
}
/// Untracked sources count; a staged new file is also unsaved tracked work.
#[test]
fn the_probe_counts_untracked_files() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/untracked").expect("add");
std::fs::write(path.join("notes.txt"), b"wip\n").unwrap();
std::fs::write(path.join("more.txt"), b"also wip\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.untracked_files, 2);
assert_eq!(w.uncommitted_files, 0);
run_git(&path, &["add", "notes.txt"]);
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1, "staged counts as uncommitted");
assert_eq!(w.untracked_files, 1);
}
/// Ignored files NEVER block: every Rust checkout has a `target/`, and
/// counting ignored output would leave no session killable (#79).
#[test]
fn the_probe_ignores_ignored_files() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// .gitignore must be committed, or it would itself read as untracked.
std::fs::write(repo.join(".gitignore"), b"/target\n").unwrap();
run_git(&repo, &["add", ".gitignore"]);
run_git(&repo, &["commit", "-q", "-m", "ignore target"]);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/builds").expect("add");
std::fs::create_dir_all(path.join("target")).unwrap();
std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 0);
assert_eq!(w.untracked_files, 0);
// With the branch pushed too, an ignored-only tree reads fully clean.
let origin_dir = TempDir::new().unwrap();
init_bare_repo(origin_dir.path());
push_all_to_origin(&repo, origin_dir.path());
let w = in_the_way(&path).expect("probe after push");
assert!(w.is_clear(), "ignored-only worktree must read clean: {w:?}");
}
/// Commits reachable from no remote-tracking ref block; pushing the branch
/// clears them. With no remote at all every commit is unreachable from one,
/// which is exactly why feature branches almost always refuse (#79).
#[test]
fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/branch").expect("add");
let w = in_the_way(&path).expect("probe");
assert!(
w.unpushed_commits >= 1,
"a fresh branch has no remote-tracking ref: {w:?}"
);
let origin_dir = TempDir::new().unwrap();
init_bare_repo(origin_dir.path());
push_all_to_origin(&repo, origin_dir.path());
let w = in_the_way(&path).expect("probe after push");
assert_eq!(w.unpushed_commits, 0, "{w:?}");
assert!(w.is_clear(), "pushed and clean must be empty: {w:?}");
}
/// The full breakdown names each blocker with its count.
#[test]
fn the_probe_reports_every_blocker_at_once() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/mixed").expect("add");
std::fs::write(path.join("README"), b"edited\n").unwrap();
std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1);
assert_eq!(w.untracked_files, 1);
assert!(w.unpushed_commits >= 1);
let summary = w.to_string();
assert!(summary.contains("1 uncommitted file"), "{summary}");
assert!(summary.contains("1 untracked file"), "{summary}");
assert!(summary.contains("unpushed commit"), "{summary}");
}
#[test]
fn remove_prunes_worktree() {
let repo_dir = TempDir::new().unwrap();
@@ -65,9 +259,9 @@ fn remove_prunes_worktree() {
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add(&repo, "feat/gone").expect("add");
let path = mgr.add_new(&repo, "feat/gone").expect("add");
assert!(path.exists());
mgr.remove(&repo, &path).expect("remove");
@@ -79,6 +273,138 @@ fn remove_prunes_worktree() {
assert_eq!(branches, vec!["main".to_string()]);
}
#[test]
fn add_existing_checks_out_branch_without_creating_it() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// Pre-create the branch with its own commit so we can tell a checkout
// from an accidental `-b`.
let created = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["checkout", "-q", "-b", "feat/dup"])
.status()
.unwrap();
assert!(created.success());
std::fs::write(repo.join("FEATURE"), b"dup\n").unwrap();
let staged = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["add", "."])
.status()
.unwrap();
assert!(staged.success());
let committed = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["commit", "-q", "-am", "wip"])
.status()
.unwrap();
assert!(committed.success());
let back = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["checkout", "-q", "main"])
.status()
.unwrap();
assert!(back.success());
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_existing(&repo, "feat/dup").expect("add existing");
assert!(path.exists());
assert!(path.join("README").exists());
// The branch set is unchanged: `feat/dup` was checked out, not recreated.
let branches = worktree_branches(&repo);
let mut sorted = branches.clone();
sorted.sort();
assert_eq!(
sorted,
vec!["feat/dup".to_string(), "main".to_string()],
"{branches:?}"
);
}
/// Removal is guarded upstream now (#79): the kill refuses unsaved work
/// before anything is torn down, so by the time `remove` runs the tree is
/// clean. Git still refuses a dirty tree on its own — this asserts that last
/// line of defense — and committed-but-unpushed work does not block removal,
/// because `worktree remove` keeps `refs/heads/<branch>` and the commits stay
/// reachable.
#[test]
fn remove_still_relies_on_git_refusing_a_dirty_tree() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
mgr.remove(&repo, &path)
.expect_err("dirty worktree must be refused");
assert!(path.exists(), "guarded worktree must stay on disk");
run_git(&path, &["add", "."]);
run_git(&path, &["commit", "-q", "-m", "saved"]);
mgr.remove(&repo, &path).expect("committed work can go");
assert!(!path.exists());
assert!(
worktree_branches(&repo).contains(&"main".to_string()),
"the branch survives removal"
);
let branches = String::from_utf8(
Command::new("git")
.arg("-C")
.arg(&repo)
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(branches.contains("feat/dirty"), "{branches}");
}
/// A worktree whose directory vanished must be pruned, not misread as holding
/// unsaved work — otherwise the registration is stranded and, because paths
/// are deterministic, that branch can never be spawned again (#56 review).
#[test]
fn remove_prunes_a_worktree_whose_directory_vanished() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/gone").expect("add");
std::fs::remove_dir_all(&path).unwrap();
mgr.remove(&repo, &path)
.expect("a vanished worktree prunes");
// The registration is gone too, so the same branch can be spawned again.
mgr.add_existing(&repo, "feat/gone")
.expect("branch is spawnable again");
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn add_fails_when_branch_already_exists() {
let repo_dir = TempDir::new().unwrap();
@@ -95,9 +421,152 @@ fn add_fails_when_branch_already_exists() {
assert!(status.success());
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(wt_root.path());
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let err = mgr.add(&repo, "feat/dup").unwrap_err();
let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("git exited"), "{msg}");
}
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
/// is easily a worktree rather than the main checkout. Placing inside it would
/// nest one worktree in another — and git hides a registered worktree's
/// contents from the outer `git status`, so the outer tree reads clean and
/// removing it deletes the inner one's uncommitted work (#54 review).
#[test]
fn placement_resolves_a_linked_worktree_to_the_main_checkout() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
// Spawn again with the *worktree* as the base, the way the picker would.
let inner = mgr.add_new(&outer, "feat/inner").expect("add inner");
assert!(
!inner.starts_with(&outer),
"must not nest: {inner:?} inside {outer:?}"
);
assert!(
inner.starts_with(repo.canonicalize().unwrap().join("worktrees")),
"placed against the main checkout: {inner:?}"
);
}
/// Belt and braces for a nesting that predates this daemon: refuse to remove
/// a worktree that has another registered underneath it.
#[test]
fn remove_refuses_a_worktree_holding_a_nested_one() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let outer = mgr.add_new(&repo, "feat/outer").expect("add outer");
// Register one by hand inside `outer`, bypassing our placement.
let nested = outer.join("worktrees").join("hand-rolled");
run_git(
&repo,
&[
"worktree",
"add",
"-b",
"feat/hand",
nested.to_str().unwrap(),
],
);
std::fs::write(nested.join("notes.txt"), b"unsaved\n").unwrap();
mgr.remove(&repo, &outer)
.expect_err("a worktree holding a nested one must be refused");
assert!(nested.join("notes.txt").exists(), "inner work survives");
}
/// Two repos spawning the same branch name under one override root must not
/// collide (#54 review).
#[test]
fn override_root_keeps_repos_apart() {
let root_dir = TempDir::new().unwrap();
let a_dir = TempDir::new().unwrap();
let b_dir = TempDir::new().unwrap();
init_repo(a_dir.path());
init_repo(b_dir.path());
let mgr = WorktreeManager::new(Some(root_dir.path().to_path_buf()));
let a = mgr.add_new(a_dir.path(), "feat/shared").expect("repo a");
let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b");
assert_ne!(a, b, "same branch name in two repos must not collide");
}
/// Flattening `/` and `.` to `-` made these three share one directory, so the
/// losers could never get a worktree (#54 review).
#[test]
fn similar_branch_names_get_distinct_directories() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let a = mgr.add_new(&repo, "feat/x").expect("feat/x");
let b = mgr.add_new(&repo, "feat.x").expect("feat.x");
let c = mgr.add_new(&repo, "feat-x").expect("feat-x");
assert_ne!(a, b);
assert_ne!(b, c);
assert_ne!(a, c);
assert!(a.ends_with("feat/x"), "{a:?}");
}
/// A guarded leftover stays on disk untouched, and is never adopted (#77).
/// Re-spawning its branch is refused by git itself — the branch is still
/// checked out inside the leftover — which the caller surfaces.
#[test]
fn a_guarded_leftover_is_never_adopted() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let first = mgr.add_new(&repo, "feat/resume").expect("add");
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
// Removal is refused — git itself refuses a dirty tree — leaving it on
// disk; the daemon-side guard lives on the kill (#79).
mgr.remove(&repo, &first)
.expect_err("dirty tree is guarded");
let err = mgr
.add_existing(&repo, "feat/resume")
.expect_err("a branch already checked out cannot be spawned twice");
assert!(format!("{err}").contains("git exited"), "{err}");
assert!(
first.join("notes.txt").exists(),
"the leftover's unsaved work is untouched"
);
}
/// The `worktree_root` override gets the same collision suffix (#77).
#[test]
fn the_override_root_suffixes_collisions_too() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
// First add settles `<root>/<repo basename>-<digest>/` so the squatter
// can be created at the exact target.
mgr.add_new(&repo, "feat/first").expect("add first");
let repo_dir_in_root = std::fs::read_dir(wt_root.path())
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let squatter = repo_dir_in_root.join("feat").join("collide");
std::fs::create_dir_all(&squatter).unwrap();
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
assert_eq!(path, squatter.with_file_name("collide-2"), "{path:?}");
}
+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
+54 -139
View File
@@ -1,15 +1,20 @@
//! Thin HTTP+WS client used by tests to drive the registry.
//! Thin test harness over [`maestro_protocol::DaemonClient`].
//!
//! Cookies are managed automatically by `reqwest`'s built-in cookie store.
//! HTTP calls delegate to the shared client so the harness exercises the same
//! request building, timeouts and error decoding the CLI and TUI use. Only the
//! WebSocket helpers are test-specific: they hand back a raw stream, or a
//! handle that accumulates events in the background.
#![allow(clippy::result_large_err)]
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{ConnectInfo, CreateSessionRequest, DaemonInfo, RegistryEvent, Session};
use reqwest::Client as HttpClient;
use std::sync::Arc;
use maestro_protocol::{
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project,
RetainedWorktree, Session,
};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
@@ -18,137 +23,86 @@ use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
#[allow(clippy::result_large_err)]
pub enum ClientError {
#[error("http: {0}")]
Http(#[from] reqwest::Error),
#[error(transparent)]
Api(#[from] maestro_protocol::ClientError),
#[error("ws: {0}")]
Ws(#[from] tokio_tungstenite::tungstenite::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("unexpected status {status}: {body}")]
Status { status: u16, body: String },
}
/// Sticky-cookie HTTP client + a few WS-aware helpers.
/// Daemon client plus a few WS-aware helpers, bound to one daemon.
#[derive(Debug, Clone)]
pub struct TestClient {
http_base: String,
ws_base: String,
http: HttpClient,
daemon: DaemonClient,
}
/// Per-request budget. Well below the client default: a hung daemon should fail
/// the test, not stall the suite for 30s.
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
impl TestClient {
pub fn new(http_base: &str, ws_base: &str) -> Self {
let http = HttpClient::builder()
.cookie_store(true)
.timeout(Duration::from_secs(5))
.build()
.expect("reqwest client");
pub fn new(http_base: &str) -> Self {
Self {
http_base: http_base.trim_end_matches('/').to_string(),
ws_base: ws_base.trim_end_matches('/').to_string(),
http,
daemon: DaemonClient::with_timeouts(http_base, TEST_TIMEOUT, TEST_TIMEOUT),
}
}
/// Hit any GET endpoint so the registry issues the auth cookie that all
/// subsequent calls reuse.
pub async fn bootstrap(&self) -> Result<(), ClientError> {
let _ = self.list_daemons().await?;
Ok(())
}
pub async fn list_daemons(&self) -> Result<Vec<DaemonInfo>, ClientError> {
let r = self
.http
.get(format!("{}/api/daemons", self.http_base))
.send()
.await?;
check_ok(&r)?;
Ok(r.json().await?)
/// The shared client underneath, for calls this harness doesn't wrap.
pub fn daemon(&self) -> &DaemonClient {
&self.daemon
}
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
let r = self
.http
.get(format!("{}/api/sessions", self.http_base))
.send()
.await?;
check_ok(&r)?;
Ok(r.json().await?)
Ok(self.daemon.list_sessions().await?)
}
pub async fn spawn_session(&self, req: &CreateSessionRequest) -> Result<Session, ClientError> {
let r = self
.http
.post(format!("{}/api/sessions", self.http_base))
.json(req)
.send()
.await?;
let status = r.status();
if !status.is_success() {
let body = r.text().await.unwrap_or_default();
return Err(ClientError::Status {
status: status.as_u16(),
body,
});
}
Ok(r.json().await?)
Ok(self.daemon.create_session(req).await?)
}
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
let r = self
.http
.delete(format!("{}/api/sessions/{id}", self.http_base))
.send()
.await?;
check_ok(&r)?;
Ok(())
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
}
pub async fn connect_info(&self, id: Uuid) -> Result<ConnectInfo, ClientError> {
let r = self
.http
.get(format!("{}/api/sessions/{id}/connect-info", self.http_base))
.send()
.await?;
check_ok(&r)?;
Ok(r.json().await?)
/// Kill with `force=true`: overrides a kill refusal and retains the
/// guarded worktree (#81). Answers with what was kept.
pub async fn kill_session_forced(&self, id: Uuid) -> Result<KillOutcome, ClientError> {
Ok(self.daemon.kill_session(id, true).await?)
}
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
Ok(self.daemon.list_worktrees().await?)
}
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
Ok(self.daemon.reclaim_worktree(id).await?)
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
Ok(self.daemon.list_projects().await?)
}
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
Ok(self.daemon.list_branches(path).await?)
}
/// Connect to the daemon's per-session WS and return the stream. The
/// caller drives the conversation.
pub async fn attach(
&self,
info: &ConnectInfo,
id: Uuid,
) -> Result<WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>, ClientError> {
let url = format!(
"{}/sessions/{id}/stream?token={}",
info.daemon_url.trim_end_matches('/'),
info.jwt
);
let request = url.into_client_request()?;
let request = self.daemon.attach_url(id)?.into_client_request()?;
let (ws, _resp) = connect_async(request).await?;
Ok(ws)
}
/// Subscribe to the registry's `/api/events` WS. Returns a tokio task
/// handle that drives the WS and pushes parsed events into an Arc<Mutex<Vec>>.
/// Subscribe to the daemon's `/api/events` WS. Returns a handle whose
/// background task pushes parsed events into a shared vector.
pub async fn subscribe_events(&self) -> EventStream {
// Forward the latest cookie value with the upgrade — `reqwest` and
// `tungstenite` don't share cookie jars, so we copy the cookie out by
// hand. The registry will accept us either way (missing cookie just
// gets a fresh one), but reusing the same identity keeps log noise
// down.
let cookie_header = self.grab_cookie().await.unwrap_or_default();
let url = format!("{}/api/events", self.ws_base);
let mut request = url.into_client_request().expect("ws req");
if !cookie_header.is_empty() {
request.headers_mut().insert(
reqwest::header::COOKIE,
cookie_header.parse().expect("cookie hv"),
);
}
let url = self.daemon.events_url().expect("events url");
let request = url.into_client_request().expect("ws req");
let (ws, _resp) = connect_async(request).await.expect("events ws");
let events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
@@ -158,7 +112,7 @@ impl TestClient {
while let Some(msg) = stream.next().await {
match msg {
Ok(WsMessage::Text(t)) => {
if let Ok(ev) = serde_json::from_str::<RegistryEvent>(&t) {
if let Ok(ev) = serde_json::from_str::<CatalogEvent>(&t) {
stream_events.lock().await.push(ev);
}
}
@@ -170,56 +124,17 @@ impl TestClient {
});
EventStream { events, task }
}
async fn grab_cookie(&self) -> Option<String> {
// Force a request to make sure the jar has a cookie, then read it back
// via a debug header roundtrip. reqwest's CookieStore isn't directly
// exposed; the simplest path is to ask the registry for an auth
// cookie and capture the Set-Cookie from the response.
let r = self
.http
.get(format!("{}/api/daemons", self.http_base))
.send()
.await
.ok()?;
// The cookie store has the cookie now, but reqwest doesn't surface
// it. We rebuild from Set-Cookie if present.
r.headers()
.get_all(reqwest::header::SET_COOKIE)
.iter()
.find_map(|hv| hv.to_str().ok().and_then(extract_cookie_pair))
}
}
fn extract_cookie_pair(set_cookie: &str) -> Option<String> {
// Take everything before the first ';', which is `name=value`.
set_cookie
.split(';')
.next()
.map(str::trim)
.map(String::from)
}
fn check_ok(r: &reqwest::Response) -> Result<(), ClientError> {
let status = r.status();
if !status.is_success() {
return Err(ClientError::Status {
status: status.as_u16(),
body: format!("HTTP {status}"),
});
}
Ok(())
}
/// Handle to a background task draining the events WS into a vector.
#[derive(Debug)]
pub struct EventStream {
events: Arc<tokio::sync::Mutex<Vec<RegistryEvent>>>,
events: Arc<tokio::sync::Mutex<Vec<CatalogEvent>>>,
task: tokio::task::JoinHandle<()>,
}
impl EventStream {
pub async fn snapshot(&self) -> Vec<RegistryEvent> {
pub async fn snapshot(&self) -> Vec<CatalogEvent> {
self.events.lock().await.clone()
}
}
+60 -129
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,72 @@ use tempfile::TempDir;
use crate::build::workspace_target_bin;
use crate::util::{free_port, wait_for_async};
/// A live `maestrod` running in a child process. Kill the child on drop.
/// A live daemon running in a child process. Kill the child on drop.
///
/// `state_dir`, `home`, and `tmux_tmpdir` are kept as `TempDir`s so they
/// outlive the process and get cleaned up automatically.
/// `home` and `tmux_tmpdir` are kept as `TempDir`s so they outlive the process
/// and get cleaned up automatically. State lives under `home` via
/// `XDG_STATE_HOME`.
#[derive(Debug)]
pub struct Daemon {
pub daemon_id: String,
pub bind_addr: SocketAddr,
pub daemon_url: String,
child: Option<Child>,
home: TempDir,
state: TempDir,
tmux: TempDir,
registry_url: String,
token: String,
}
/// Optional knobs for `Daemon::spawn_with` — perimeter 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(),
/// Spawn a fresh daemon on a free localhost port.
pub async fn spawn() -> Self {
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
}
/// Spawn with a config file pre-seeded at the daemon's expected path
/// (`$XDG_CONFIG_HOME/maestro/config.toml`).
pub async fn spawn_with_config(toml: &str) -> Self {
Self::spawn_config_at(
SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())),
Some(toml),
)
.await
}
/// Spawn with NAP-247 options (perimeter advertisement).
pub async fn spawn_with(
daemon_id: &str,
registry_base_url: &str,
token: &str,
opts: DaemonOptions,
) -> Self {
let port = free_port();
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
Self::spawn_with_port_and_opts(daemon_id, registry_base_url, token, bind, opts).await
/// Spawn on a caller-supplied bind address.
pub async fn spawn_at(bind: SocketAddr) -> Self {
Self::spawn_config_at(bind, None).await
}
/// Spawn on a caller-supplied bind address. Used by NAP-247 broker tests
/// where the daemon's advertised `private_addr` must equal the actual
/// bind address so the broker can dial it back.
pub async fn spawn_at(
daemon_id: &str,
registry_base_url: &str,
token: &str,
bind: SocketAddr,
opts: DaemonOptions,
) -> Self {
Self::spawn_with_port_and_opts(daemon_id, registry_base_url, token, bind, opts).await
/// Spawn on a caller-supplied bind address, with an optional config file.
async fn spawn_config_at(bind: SocketAddr, config: impl Into<Option<&str>>) -> Self {
let home = TempDir::new().expect("home tmpdir");
let tmux = TempDir::new().expect("tmux tmpdir");
if let Some(toml) = config.into() {
let dir = home.path().join(".config").join("maestro");
std::fs::create_dir_all(&dir).expect("config dir");
std::fs::write(dir.join("config.toml"), toml).expect("write config.toml");
}
let child = launch(bind, home.path(), tmux.path());
let me = Self {
bind_addr: bind,
child: Some(child),
home,
tmux,
};
me.wait_until_ready().await;
me
}
/// HTTP base URL of this daemon's API.
pub fn http_base(&self) -> String {
format!("http://{}", self.bind_addr)
}
/// Shared client bound to this daemon, with the harness's tighter budget.
pub fn client(&self) -> maestro_protocol::DaemonClient {
crate::client::TestClient::new(&self.http_base())
.daemon()
.clone()
}
/// Stop the underlying process, but keep its state dirs around so we can
@@ -85,70 +89,18 @@ impl Daemon {
/// Re-spawn the daemon on the same port + state. The previous tmux
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
/// its sessions. Restarts inherit the original options via the on-disk
/// config file written at `spawn_with_port_and_opts` time.
/// its sessions.
pub async fn restart(&mut self) {
self.stop();
let child = launch(
&self.daemon_id,
self.bind_addr,
&self.daemon_url,
&self.registry_url,
&self.token,
self.home.path(),
self.state.path(),
self.tmux.path(),
&DaemonOptions::default(),
);
self.child = Some(child);
self.wait_until_attach_ready().await;
self.child = Some(launch(self.bind_addr, self.home.path(), self.tmux.path()));
self.wait_until_ready().await;
}
async fn spawn_with_port_and_opts(
daemon_id: &str,
registry_url: &str,
token: &str,
bind: SocketAddr,
opts: DaemonOptions,
) -> Self {
let home = TempDir::new().expect("home tmpdir");
let state = TempDir::new().expect("state tmpdir");
let tmux = TempDir::new().expect("tmux tmpdir");
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 +110,7 @@ impl Daemon {
pub fn state_dir(&self) -> PathBuf {
// Matches maestro_daemon::default_state_dir which uses XDG_STATE_HOME.
self.home.path().join(".local/state/maestrod")
self.home.path().join(".local/state/maestro")
}
}
@@ -171,18 +123,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 +136,11 @@ fn launch(
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("XDG_STATE_HOME", home.join(".local/state"))
.env("TMUX_TMPDIR", tmux_tmpdir)
.env("MAESTRO_TOKEN", token)
.env("MAESTRO_DAEMON_ID", daemon_id)
.env("MAESTRO_REGISTRY_URL", registry_url)
.env("MAESTRO_DAEMON_BIND_ADDR", bind.to_string())
.env("MAESTRO_DAEMON_URL", daemon_url)
.env("RUST_LOG", "warn")
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.arg("run");
if let Some(p) = &opts.perimeter_id {
cmd.env("MAESTRO_DAEMON_PERIMETER_ID", p);
}
if let Some(a) = &opts.private_addr {
cmd.env("MAESTRO_DAEMON_PRIVATE_ADDR", a);
}
cmd.spawn()
.unwrap_or_else(|e| panic!("spawn {} failed: {e}", bin.display()))
}
+5 -7
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 client::{ClientError, TestClient};
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();
}
}
}
File diff suppressed because it is too large Load Diff
-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!(
+1 -4
View File
@@ -5,10 +5,7 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "Orchestra-flavoured session-handle generator (adjective × instrument) shared across Maestro"
description = "Session-handle generator: musical adjective-noun wordlists"
[dependencies]
rand.workspace = true
[dev-dependencies]
rand = { workspace = true, features = ["small_rng"] }
+35 -153
View File
@@ -1,24 +1,7 @@
//! `maestro-names` — the shared session-handle generator.
//! The one home of the session-handle wordlists (#53).
//!
//! 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;
//! Previously duplicated in `web/src/names.ts`; every client now gets handles
//! from here, served by `GET /api/names`.
/// Musical adjectives — tempo, dynamics, and articulation markings.
pub const ADJECTIVES: &[&str] = &[
@@ -62,153 +45,52 @@ pub const NOUNS: &[&str] = &[
"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}")
/// Upper bound for a `GET /api/names?count=N` batch.
pub const MAX_BATCH: usize = 100;
/// A fresh `adjective-noun` handle, e.g. `allegro-cello`.
pub fn generate() -> String {
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
format!(
"{}-{}",
ADJECTIVES.choose(&mut rng).unwrap(),
NOUNS.choose(&mut rng).unwrap()
)
}
/// 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())
/// `count` fresh handles (the caller clamps; this only trusts its input).
pub fn generate_batch(count: usize) -> Vec<String> {
(0..count).map(|_| generate()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::SmallRng;
use rand::SeedableRng;
use std::collections::HashSet;
fn seeded(seed: u64) -> SmallRng {
SmallRng::seed_from_u64(seed)
#[test]
fn generated_name_is_adjective_dash_noun() {
let name = generate();
let (adj, noun) = name.split_once('-').expect("name contains a dash");
assert!(ADJECTIVES.contains(&adj), "{adj} not in ADJECTIVES");
assert!(NOUNS.contains(&noun), "{noun} not in NOUNS");
}
#[test]
fn 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));
fn batch_returns_the_requested_count() {
assert_eq!(generate_batch(1).len(), 1);
assert_eq!(generate_batch(50).len(), 50);
for name in generate_batch(10) {
let (adj, noun) = name.split_once('-').unwrap();
assert!(ADJECTIVES.contains(&adj) && NOUNS.contains(&noun));
}
}
#[test]
fn 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"));
fn rerolls_can_differ() {
// 30x30=900 combinations; 20 draws all matching has probability ~1e-40.
let first = generate();
let differs = (0..20).any(|_| generate() != first);
assert!(differs);
}
}
+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
+241
View File
@@ -0,0 +1,241 @@
//! Typed client for the daemon's `/api/*` surface.
//!
//! Behind the `client` feature so consumers that only need the wire types and
//! route paths — `maestro-web`, `maestro-config` — don't pull an HTTP stack.
//!
//! This is the one place that knows how to call the daemon: the CLI, the TUI and
//! the end-to-end harness all go through it.
use std::time::Duration;
use reqwest::{Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
KillOutcome, Project, RecentDirs, RetainedWorktree, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Total budget for a single request. Bounds the case where the daemon is
/// TCP-alive but never makes forward progress.
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("http: {0}")]
Http(#[from] reqwest::Error),
/// The daemon answered with a structured [`ApiError`] envelope.
#[error("daemon returned {status} ({}): {}", .info.code, .info.message)]
Api { status: StatusCode, info: ErrorInfo },
/// A non-success response whose body was not an [`ApiError`].
#[error("daemon returned {status}: {body}")]
Status { status: StatusCode, body: String },
#[error("decode: {0}")]
Json(#[from] serde_json::Error),
#[error(transparent)]
Url(#[from] UrlError),
}
impl ClientError {
/// True when the daemon could not be reached at all, rather than reaching it
/// and being refused. Drives "is the daemon running?" messaging.
pub fn is_unreachable(&self) -> bool {
matches!(self, ClientError::Http(e) if e.is_connect())
}
/// The daemon's own message when it sent one, so callers can render the
/// reason rather than a bare status code.
pub fn daemon_message(&self) -> Option<&str> {
match self {
ClientError::Api { info, .. } => Some(&info.message),
_ => None,
}
}
}
/// HTTP client bound to one daemon's base URL.
///
/// The daemon's surface needs no auth — reaching its bind address is the
/// authorization, same trust model as tmux itself.
#[derive(Debug, Clone)]
pub struct DaemonClient {
base: String,
http: reqwest::Client,
}
impl DaemonClient {
/// Client with the standard timeouts.
pub fn new(base: impl Into<String>) -> Self {
Self::with_timeouts(base, DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT)
}
/// Client with explicit timeouts, for callers with a tighter budget than the
/// default — the TUI's startup health probe wants to fail fast rather than
/// hold the terminal for 30 seconds.
pub fn with_timeouts(base: impl Into<String>, connect: Duration, request: Duration) -> Self {
let http = reqwest::Client::builder()
.user_agent(concat!("maestro/", env!("CARGO_PKG_VERSION")))
.connect_timeout(connect)
.timeout(request)
.build()
.expect("build reqwest client");
Self {
base: routes::normalize_base(&base.into()).to_string(),
http,
}
}
pub fn base(&self) -> &str {
&self.base
}
/// `WS /api/events` URL for this daemon.
pub fn events_url(&self) -> Result<String, ClientError> {
Ok(routes::events_url(&self.base)?)
}
/// `WS /api/attach/:id` URL for this daemon.
pub fn attach_url(&self, id: Uuid) -> Result<String, ClientError> {
Ok(routes::attach_url(&self.base, id)?)
}
// -- typed calls --------------------------------------------------------
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
self.get(routes::SESSIONS, &[]).await
}
pub async fn create_session(&self, req: &CreateSessionRequest) -> Result<Session, ClientError> {
self.post(routes::SESSIONS, req).await
}
/// Kill a session. With `force`, the daemon overrides the kill refusal
/// (#79) and retains the worktree instead of removing it (#81); the
/// outcome then names what was kept and where.
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<KillOutcome, ClientError> {
let query: &[(&str, &str)] = if force {
&[(routes::FORCE_QUERY, "true")]
} else {
&[]
};
let resp = self
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
.await?;
let resp = check(resp).await?;
// An unforced kill keeps its historical `204 No Content`.
if resp.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(KillOutcome { retained: None });
}
json(resp).await
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await
}
pub async fn list_dir(&self, path: &str) -> Result<DirListing, ClientError> {
self.get(routes::DIRS, &[(routes::PATH_QUERY, path)]).await
}
pub async fn recent_dirs(&self) -> Result<RecentDirs, ClientError> {
self.get(routes::RECENT_DIRS, &[]).await
}
pub async fn git_root(&self, path: &str) -> Result<GitRoot, ClientError> {
self.get(routes::GITROOT, &[(routes::PATH_QUERY, path)])
.await
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
self.get(routes::PROJECTS, &[]).await
}
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)])
.await
}
/// `GET /api/worktrees` (#80): every retained worktree with its in-the-way
/// breakdown as of this read.
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
self.get(routes::WORKTREES, &[]).await
}
/// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The
/// daemon removes the directory and drops the store entry; the branch is
/// never deleted.
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
let resp = self
.send(Method::DELETE, &routes::worktree(id), &[], None::<&()>)
.await?;
check(resp).await?;
Ok(())
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, ClientError> {
let resp = self.send(Method::GET, path, query, None::<&()>).await?;
json(check(resp).await?).await
}
async fn post<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<T, ClientError> {
let resp = self.send(Method::POST, path, &[], Some(body)).await?;
json(check(resp).await?).await
}
async fn send<B: Serialize>(
&self,
method: Method,
path: &str,
query: &[(&str, &str)],
body: Option<&B>,
) -> Result<Response, ClientError> {
let mut req = self
.http
.request(method, routes::http_url(&self.base, path));
if !query.is_empty() {
req = req.query(query);
}
if let Some(b) = body {
req = req.json(b);
}
Ok(req.send().await?)
}
}
/// Turn a non-success response into the richest error its body supports: the
/// daemon's [`ApiError`] envelope when present, the raw body otherwise.
async fn check(resp: Response) -> Result<Response, ClientError> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let body = resp.text().await.unwrap_or_default();
match serde_json::from_str::<ApiError>(&body) {
Ok(api) => Err(ClientError::Api {
status,
info: api.error,
}),
Err(_) => Err(ClientError::Status { status, body }),
}
}
async fn json<T: DeserializeOwned>(resp: Response) -> Result<T, ClientError> {
// `204 No Content` is a valid success for the calls that return `()`; the
// typed getters never hit it.
Ok(resp.json::<T>().await?)
}
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
//! The daemon's `/api/*` surface: paths, and the scheme swap every client
//! needs to reach the WebSocket endpoints.
//!
//! Everything that knows what the daemon's URLs look like lives here.
use uuid::Uuid;
/// Prefix every daemon route shares. `maestro-web` uses it to decide whether a
/// request is proxied to the daemon or served from the SPA bundle.
pub const API_PREFIX: &str = "/api";
/// `GET` list / `POST` spawn.
pub const SESSIONS: &str = "/api/sessions";
/// `GET` daemon identity, version, and spawn presets.
pub const INFO: &str = "/api/info";
/// `GET` one directory's immediate subdirectories. Takes a `path` query param.
pub const DIRS: &str = "/api/dirs";
/// `GET` spawn history.
pub const RECENT_DIRS: &str = "/api/recent-dirs";
/// `GET` discovered git repositories and worktrees, newest activity first.
pub const PROJECTS: &str = "/api/projects";
/// `GET` the enclosing repository of a path. Takes a `path` query param.
pub const GITROOT: &str = "/api/gitroot";
/// `GET` one repository's local branches. Takes a `path` query param.
pub const BRANCHES: &str = "/api/branches";
/// `GET` a batch of generated session handles (#53). Takes a `count` query
/// param (default 1, capped at [`maestro_names`]' batch cap).
pub const NAMES: &str = "/api/names";
/// `GET` retained worktrees (#80): what the store holds, with the in-the-way
/// breakdown recomputed on read.
pub const WORKTREES: &str = "/api/worktrees";
/// `WS` catalog snapshot + session deltas.
pub const EVENTS: &str = "/api/events";
/// Name of the query parameter [`DIRS`], [`GITROOT`], and [`BRANCHES`] take.
pub const PATH_QUERY: &str = "path";
/// Name of the query parameter [`NAMES`] takes.
pub const COUNT_QUERY: &str = "count";
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
/// `force=true` overrides the kill refusal and retains the worktree.
pub const FORCE_QUERY: &str = "force";
/// `DELETE /api/sessions/:id`.
pub fn session(id: Uuid) -> String {
format!("{SESSIONS}/{id}")
}
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
pub fn worktree(id: Uuid) -> String {
format!("{WORKTREES}/{id}")
}
/// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}")
}
/// Axum-style patterns for the two parameterised routes, so the server
/// registers the same paths [`session`] and [`attach`] build.
pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id";
pub const ATTACH: &str = "/api/attach/:id";
pub const WORKTREE: &str = "/api/worktrees/:id";
}
/// A daemon base URL that could not be turned into a WebSocket base.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UrlError {
#[error("daemon url has no supported scheme (want http/https/ws/wss): {0}")]
UnsupportedScheme(String),
}
/// Strip a trailing slash so `{base}{path}` never doubles up.
pub fn normalize_base(base: &str) -> &str {
base.trim_end_matches('/')
}
/// Join a daemon base URL and one of this module's paths.
pub fn http_url(base: &str, path: &str) -> String {
format!("{}{}", normalize_base(base), path)
}
/// Swap an HTTP base URL for its WebSocket equivalent.
///
/// `http://` becomes `ws://`, `https://` becomes `wss://`, and a base that is
/// already `ws://`/`wss://` passes through. Anything else is an error — a
/// scheme-less base would otherwise produce a URL that fails much later, at
/// connect time, with nothing pointing back at the configuration that caused
/// it.
pub fn ws_base(http_base: &str) -> Result<String, UrlError> {
let base = normalize_base(http_base);
if let Some(rest) = base.strip_prefix("https://") {
Ok(format!("wss://{rest}"))
} else if let Some(rest) = base.strip_prefix("http://") {
Ok(format!("ws://{rest}"))
} else if base.starts_with("wss://") || base.starts_with("ws://") {
Ok(base.to_string())
} else {
Err(UrlError::UnsupportedScheme(http_base.to_string()))
}
}
/// Full `WS /api/events` URL for a daemon reachable at `http_base`.
pub fn events_url(http_base: &str) -> Result<String, UrlError> {
Ok(format!("{}{}", ws_base(http_base)?, EVENTS))
}
/// Full `WS /api/attach/:id` URL for a daemon reachable at `http_base`.
pub fn attach_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
}
#[cfg(test)]
mod tests {
use super::*;
fn id() -> Uuid {
Uuid::parse_str("6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50").unwrap()
}
#[test]
fn ws_base_swaps_http_and_https() {
assert_eq!(
ws_base("http://127.0.0.1:7070").unwrap(),
"ws://127.0.0.1:7070"
);
assert_eq!(
ws_base("https://laptop.lan:7070").unwrap(),
"wss://laptop.lan:7070"
);
}
#[test]
fn ws_base_passes_through_websocket_schemes() {
assert_eq!(ws_base("ws://10.0.0.1:7070").unwrap(), "ws://10.0.0.1:7070");
assert_eq!(
ws_base("wss://10.0.0.1:7070").unwrap(),
"wss://10.0.0.1:7070"
);
}
#[test]
fn ws_base_rejects_an_unknown_scheme() {
assert_eq!(
ws_base("127.0.0.1:7070"),
Err(UrlError::UnsupportedScheme("127.0.0.1:7070".into()))
);
assert!(ws_base("ftp://host:7070").is_err());
}
#[test]
fn trailing_slashes_never_double_up() {
assert_eq!(ws_base("http://host:7070/").unwrap(), "ws://host:7070");
assert_eq!(
http_url("http://host:7070/", SESSIONS),
"http://host:7070/api/sessions"
);
assert_eq!(
events_url("http://host:7070/").unwrap(),
"ws://host:7070/api/events"
);
}
#[test]
fn builds_per_session_paths() {
assert_eq!(
session(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
worktree(id()),
"/api/worktrees/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
attach_url("https://laptop.lan:7070", id()).unwrap(),
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
}
/// The server registers `patterns::*` while clients call `session`/`attach`;
/// nothing but this test keeps the two spellings in step.
#[test]
fn patterns_and_builders_agree() {
assert_eq!(
patterns::SESSION.trim_end_matches(":id"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::WORKTREE.trim_end_matches(":id"),
worktree(id()).trim_end_matches(&id().to_string())
);
}
#[test]
fn every_route_sits_under_the_api_prefix() {
for path in [
SESSIONS,
INFO,
DIRS,
RECENT_DIRS,
PROJECTS,
GITROOT,
BRANCHES,
NAMES,
WORKTREES,
EVENTS,
] {
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
}
assert!(attach(id()).starts_with(API_PREFIX));
}
}
-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
+366
View File
@@ -0,0 +1,366 @@
//! An in-memory Maestro daemon for tests.
//!
//! Serves the real `/api/*` surface from `maestro-protocol` types, backed by a
//! `Vec<Session>` instead of tmux. Use it whenever the subject under test is a
//! client — request building, error decoding, the catalog reducer, the proxy —
//! rather than session management itself. Tests that genuinely need tmux belong
//! in `maestro-e2e` against a real daemon.
#![deny(missing_debug_implementations)]
use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::sync::{Arc, Mutex};
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get};
use axum::{Json, Router};
use chrono::Utc;
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonClient, DaemonInfo, DirListing,
GitRoot, RecentDirs, Session, SessionStatus, PROTOCOL_VERSION,
};
use tokio::net::TcpListener;
use tokio::sync::{broadcast, oneshot};
use uuid::Uuid;
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
Session {
id,
cwd: cwd.into(),
command: command.into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
#[derive(Debug, Default)]
struct Inner {
sessions: Vec<Session>,
info: Option<DaemonInfo>,
/// When set, every route answers with this instead of its normal response.
forced_error: Option<(u16, ApiError)>,
}
#[derive(Clone)]
struct AppState {
inner: Arc<Mutex<Inner>>,
events: broadcast::Sender<CatalogEvent>,
}
impl std::fmt::Debug for AppState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppState").finish_non_exhaustive()
}
}
/// A running mock daemon. Shuts down when dropped.
#[derive(Debug)]
pub struct MockDaemon {
addr: SocketAddr,
state: AppState,
_shutdown: oneshot::Sender<()>,
}
impl MockDaemon {
pub async fn start() -> Self {
let state = AppState {
inner: Arc::new(Mutex::new(Inner::default())),
events: broadcast::channel(64).0,
};
let app = Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::INFO, get(daemon_info))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
.route(routes::GITROOT, get(git_root))
.route(routes::EVENTS, any(events_ws))
.route(routes::patterns::ATTACH, any(attach_ws))
.with_state(state.clone());
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await
.expect("bind");
let addr = listener.local_addr().expect("local_addr");
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
Self {
addr,
state,
_shutdown: tx,
}
}
pub fn addr(&self) -> SocketAddr {
self.addr
}
pub fn http_base(&self) -> String {
format!("http://{}", self.addr)
}
/// A client bound to this daemon.
pub fn client(&self) -> DaemonClient {
DaemonClient::new(self.http_base())
}
/// Add a session and announce it, as a real spawn would.
pub fn add_session(&self, session: Session) {
self.state
.inner
.lock()
.expect("poisoned")
.sessions
.push(session.clone());
let _ = self
.state
.events
.send(CatalogEvent::SessionAdded { session });
}
/// Remove a session and announce it.
pub fn remove_session(&self, id: Uuid) {
self.state
.inner
.lock()
.expect("poisoned")
.sessions
.retain(|s| s.id != id);
let _ = self.state.events.send(CatalogEvent::SessionRemoved { id });
}
pub fn set_info(&self, info: DaemonInfo) {
self.state.inner.lock().expect("poisoned").info = Some(info);
}
/// Make every route answer with this error, so clients' failure paths — and
/// their [`ApiError`] decoding — can be exercised.
pub fn force_error(&self, status: u16, code: &str, message: &str) {
self.state.inner.lock().expect("poisoned").forced_error =
Some((status, ApiError::new(code, message)));
}
pub fn clear_error(&self) {
self.state.inner.lock().expect("poisoned").forced_error = None;
}
pub fn session_count(&self) -> usize {
self.state.inner.lock().expect("poisoned").sessions.len()
}
}
fn forced(state: &AppState) -> Option<Response> {
let guard = state.inner.lock().expect("poisoned");
guard.forced_error.as_ref().map(|(status, body)| {
(
StatusCode::from_u16(*status).expect("valid status"),
Json(body.clone()),
)
.into_response()
})
}
async fn list_sessions(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let sessions = s.inner.lock().expect("poisoned").sessions.clone();
Json(sessions).into_response()
}
async fn create_session(
State(s): State<AppState>,
Json(req): Json<CreateSessionRequest>,
) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let mut created = session(Uuid::new_v4(), &req.cwd.to_string_lossy(), &req.command);
created.env = req.env;
created.metadata = req.metadata;
s.inner
.lock()
.expect("poisoned")
.sessions
.push(created.clone());
let _ = s.events.send(CatalogEvent::SessionAdded {
session: created.clone(),
});
Json(created).into_response()
}
async fn kill_session(State(s): State<AppState>, Path(id): Path<Uuid>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let existed = {
let mut guard = s.inner.lock().expect("poisoned");
let before = guard.sessions.len();
guard.sessions.retain(|x| x.id != id);
guard.sessions.len() != before
};
if !existed {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
let _ = s.events.send(CatalogEvent::SessionRemoved { id });
StatusCode::NO_CONTENT.into_response()
}
async fn daemon_info(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
let info = s
.inner
.lock()
.expect("poisoned")
.info
.clone()
.unwrap_or_else(|| DaemonInfo {
hostname: "mock-host".into(),
version: PROTOCOL_VERSION.into(),
home_dir: "/home/mock".into(),
presets: Vec::new(),
});
Json(info).into_response()
}
async fn list_dir(State(s): State<AppState>, Query(q): Query<PathQuery>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
Json(DirListing {
path: q.path.into(),
entries: Vec::new(),
truncated: false,
})
.into_response()
}
async fn recent_dirs(State(s): State<AppState>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
Json(RecentDirs {
dirs: Vec::new(),
truncated: false,
})
.into_response()
}
async fn git_root(State(s): State<AppState>, Query(_q): Query<PathQuery>) -> Response {
if let Some(r) = forced(&s) {
return r;
}
Json(GitRoot { root: None }).into_response()
}
/// Snapshot on connect, then live deltas — the same contract the daemon offers.
async fn events_ws(State(s): State<AppState>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(move |socket| events_loop(s, socket))
}
async fn events_loop(s: AppState, socket: WebSocket) {
let (mut sink, mut stream) = socket.split();
let mut rx = s.events.subscribe();
let snapshot = CatalogEvent::Snapshot {
sessions: s.inner.lock().expect("poisoned").sessions.clone(),
};
if sink
.send(Message::Text(
serde_json::to_string(&snapshot).expect("encode"),
))
.await
.is_err()
{
return;
}
loop {
tokio::select! {
ev = rx.recv() => {
let Ok(ev) = ev else { break };
let text = serde_json::to_string(&ev).expect("encode");
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
frame = stream.next() => {
match frame {
None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break,
Some(Ok(_)) => {}
}
}
}
}
}
/// Echoes whatever it is sent, so a client's PTY plumbing can be exercised.
async fn attach_ws(
State(s): State<AppState>,
Path(id): Path<Uuid>,
ws: WebSocketUpgrade,
) -> Response {
let known = s
.inner
.lock()
.expect("poisoned")
.sessions
.iter()
.any(|x| x.id == id);
if !known {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("not_found", "no such session on this daemon")),
)
.into_response();
}
ws.on_upgrade(|mut socket| async move {
while let Some(Ok(msg)) = socket.next().await {
match msg {
Message::Binary(b) => {
if socket.send(Message::Binary(b)).await.is_err() {
break;
}
}
Message::Text(t) => {
if socket.send(Message::Text(t)).await.is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
})
}
#[derive(Debug, serde::Deserialize)]
struct PathQuery {
#[serde(default)]
path: String,
}
+3 -1
View File
@@ -8,11 +8,12 @@ publish.workspace = true
description = "Terminal UI client over the local maestro daemon's `/api/*` surface."
[dependencies]
chrono.workspace = true
clap.workspace = true
crossterm.workspace = true
futures-util.workspace = true
maestro-config.workspace = true
maestro-protocol.workspace = true
maestro-protocol = { workspace = true, features = ["client"] }
ratatui.workspace = true
reqwest.workspace = true
serde_json.workspace = true
@@ -24,6 +25,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"] }
+30 -2
View File
@@ -38,8 +38,13 @@ pub enum Action {
// Catalog actions on the highlighted sidebar row
AttachHighlighted,
ToggleAttachHighlighted,
OpenSpawnModal,
NewDraftPane,
/// Fast-forward spawn (#67): spawn immediately with every default
/// resolved; unresolvable defaults open a pre-filled draft instead.
FastForwardSpawn,
KillHighlighted,
/// Open the retained-worktrees view (#85).
ShowWorktrees,
// Tabs
NewTab,
@@ -78,8 +83,10 @@ impl Action {
"SidebarPrev" => return Ok(Action::SidebarPrev),
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
"NewDraftPane" => return Ok(Action::NewDraftPane),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
"NextTab" => return Ok(Action::NextTab),
@@ -197,11 +204,22 @@ mod tests {
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
}
/// The fast-forward pair sits next to the draft-pane key (#67).
#[test]
fn fast_forward_spawn_round_trips() {
assert_eq!(
Action::parse("FastForwardSpawn").unwrap(),
Action::FastForwardSpawn
);
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
}
#[test]
fn display_round_trips() {
for a in [
Action::EnterInsert,
Action::Quit,
Action::NewDraftPane,
Action::FocusPane(Direction::Right),
Action::GotoTab(7),
] {
@@ -209,4 +227,14 @@ mod tests {
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
}
}
/// The spawn modal is gone (issue #63); its old action string must not
/// resolve to anything.
#[test]
fn the_removed_spawn_modal_action_no_longer_parses() {
assert_eq!(
Action::parse("OpenSpawnModal").unwrap_err(),
ActionParseError::Unknown("OpenSpawnModal".into())
);
}
}
File diff suppressed because it is too large Load Diff
+32 -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,115 +179,62 @@ 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(),
metadata: Default::default(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
last_activity_at: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
}
}
#[test]
fn snapshot_event_replaces_state() {
let store = CatalogStore::default();
store.apply_event(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 +243,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 +256,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 +273,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 +289,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")
+37 -1
View File
@@ -45,8 +45,12 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("up", "SidebarPrev"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "OpenSpawnModal"),
("s", "NewDraftPane"),
// Fast-forward spawn (#67): the draft-pane key's neighbour does the same
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
("w", "ShowWorktrees"),
// Tabs
("ctrl-t", "NewTab"),
("g t", "NextTab"),
@@ -155,6 +159,18 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "g t"),
Some(Action::NextTab),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "S"),
Some(Action::FastForwardSpawn),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "w"),
Some(Action::ShowWorktrees),
);
}
#[test]
@@ -222,6 +238,26 @@ mod tests {
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
}
/// Regression for #70: the previous canonical form of a real Shift+S
/// keypress was `"shift-S"`, which the default `"S"` binding never
/// matched. Drive the actual crossterm event, not the binding string,
/// so the dead-key bug can't come back unnoticed.
#[test]
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
use crate::input::{canonicalise, Region};
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
assert_eq!(
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
Some(Action::FastForwardSpawn),
);
}
#[test]
fn custom_chord_timeout_is_applied() {
let mut section = TuiSection::default();
File diff suppressed because it is too large Load Diff
+282
View File
@@ -0,0 +1,282 @@
//! Fast-forward spawn (#67): one keystroke, every default resolved, no form.
//!
//! The defaults mirror what a draft pane would settle on after the operator
//! picked the most recent project and kept their hands off everything else:
//! its last command (or the first non-custom preset), a freshly generated
//! name, and a worktree with a branch named after that name. Any default
//! that cannot be resolved — no projects yet, no command, no generated
//! name — downgrades the keystroke to a draft pane pre-filled with whatever
//! did resolve ([`FastForwardDefaults::apply_to_draft`]).
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use crate::draft::DraftPane;
/// What one fast-forward keystroke resolved. Every field optional: `None`
/// anywhere means "open a draft instead of spawning".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FastForwardDefaults {
pub project: Option<Project>,
pub command: Option<String>,
pub name: Option<String>,
}
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
/// caller falls back to the built-ins it already has).
pub fn resolve(
projects: &[Project],
presets: &[SpawnPreset],
name: Option<String>,
now: DateTime<Utc>,
) -> FastForwardDefaults {
let project = crate::projects::sort_projects(projects.to_vec(), now)
.into_iter()
.next();
let command = project
.as_ref()
.and_then(|p| p.last_command.clone())
.filter(|c| !c.trim().is_empty())
.or_else(|| {
presets
.iter()
.find(|p| !p.command.is_empty())
.map(|p| p.command.clone())
});
FastForwardDefaults {
project,
command,
name,
}
}
impl FastForwardDefaults {
/// The spawn request when *every* default resolved: worktree on, new
/// branch named after the generated session name, base repo from the
/// scan (`None` means the project is itself a main checkout).
pub fn build_request(&self) -> Option<CreateSessionRequest> {
let project = self.project.as_ref()?;
let command = self.command.as_deref()?.trim();
let name = self.name.as_deref()?;
if command.is_empty() || name.is_empty() {
return None;
}
let mut metadata = std::collections::HashMap::new();
metadata.insert(
"name".to_string(),
serde_json::Value::String(name.to_string()),
);
Some(CreateSessionRequest {
cwd: project.path.clone(),
command: command.to_string(),
env: Default::default(),
metadata,
worktree: Some(WorktreeSpec {
base_repo: project
.base_repo
.clone()
.unwrap_or_else(|| project.path.clone()),
branch: name.to_string(),
create_branch: true,
}),
})
}
/// Seed a fresh draft pane with whatever did resolve — the fallback when
/// [`Self::build_request`] is `None`. The operator fills in the rest.
pub fn apply_to_draft(&self, draft: &mut DraftPane) {
if let Some(project) = &self.project {
draft.picked_cwd = Some(PathBuf::from(&project.path));
// The scan already knows this directory's repo (#61): no probe
// round trip needed, exactly like picking the row by hand.
draft.base_repo = Some(
project
.base_repo
.clone()
.unwrap_or_else(|| project.path.clone()),
);
draft.probed_cwd = Some(PathBuf::from(&project.path));
}
if let Some(command) = &self.command {
if let Some(idx) = draft.presets.iter().position(|p| &p.command == command) {
draft.selected_preset = idx;
} else if !command.trim().is_empty() {
// A last_command no preset advertises lands in custom free text.
draft.selected_preset = draft.presets.len().saturating_sub(1);
draft.custom_command = crate::text_input::TextInput::with_value(command);
}
}
if let Some(name) = &self.name {
draft.set_name(name.clone());
// Seeded, not generated: a later refill must not clobber it.
draft.name_touched = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeDelta;
fn project(path: &str, days_ago: i64, cmd: Option<&str>) -> Project {
Project {
path: PathBuf::from(path),
name: path.rsplit('/').next().unwrap_or(path).into(),
is_worktree: false,
base_repo: None,
last_activity: Some(Utc::now() - TimeDelta::days(days_ago)),
last_command: cmd.map(String::from),
}
}
fn repo_project(path: &str, base: &str, days_ago: i64, cmd: Option<&str>) -> Project {
Project {
base_repo: Some(PathBuf::from(base)),
..project(path, days_ago, cmd)
}
}
fn preset(id: &str, command: &str) -> SpawnPreset {
SpawnPreset {
id: id.into(),
label: id.into(),
command: command.into(),
}
}
#[test]
fn resolves_every_default_from_the_most_recent_project() {
let projects = vec![
project("/repos/old", 30, Some("zsh")),
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
];
let presets = vec![preset("aider", "aider"), preset("custom", "")];
let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now());
assert_eq!(
r.project.as_ref().unwrap().path,
PathBuf::from("/repos/fresh")
);
assert_eq!(r.command.as_deref(), Some("claude"));
assert_eq!(r.name.as_deref(), Some("amber-falcon"));
let req = r.build_request().expect("all defaults resolved");
assert_eq!(req.cwd, PathBuf::from("/repos/fresh"));
assert_eq!(req.command, "claude");
assert_eq!(
req.metadata.get("name").and_then(|v| v.as_str()),
Some("amber-falcon")
);
let wt = req.worktree.expect("worktree on");
assert_eq!(wt.base_repo, PathBuf::from("/repos/fresh"));
assert_eq!(wt.branch, "amber-falcon");
assert!(wt.create_branch, "fresh branch named after the session");
}
#[test]
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
let r = resolve(
&projects,
&[preset("zsh", "zsh")],
Some("s".into()),
Utc::now(),
);
let wt = r.build_request().unwrap().worktree.unwrap();
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
}
#[test]
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
let projects = vec![project("/repos/p", 0, None)];
let presets = vec![preset("custom", ""), preset("claude", "claude")];
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
assert_eq!(r.command.as_deref(), Some("claude"));
}
#[test]
fn an_empty_last_command_is_no_command_at_all() {
let projects = vec![project("/repos/p", 0, Some(""))];
let presets = vec![preset("custom", "")];
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
assert_eq!(r.command, None);
assert!(r.build_request().is_none());
}
#[test]
fn nothing_resolves_when_no_projects_are_known_yet() {
let r = resolve(
&[],
&[preset("claude", "claude")],
Some("n".into()),
Utc::now(),
);
assert_eq!(r.project, None);
// No project → no directory → no request, even with command + name.
assert!(r.build_request().is_none());
}
#[test]
fn a_missing_generated_name_downgrades_to_a_draft() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
assert!(r.build_request().is_none(), "no name → no immediate spawn");
}
#[test]
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now());
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
assert_eq!(draft.cwd(), Some(PathBuf::from("/repos/p")));
assert_eq!(draft.base_repo, Some(PathBuf::from("/repos/p")));
assert_eq!(draft.command(), "aider");
assert!(
draft.name.value().is_empty(),
"nothing to seed the name with"
);
assert!(draft.worktree_enabled);
}
#[test]
fn a_resolved_name_survives_late_refills_and_tracks_the_branch() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let named = resolve(
&projects,
&[preset("claude", "claude")],
Some("amber-falcon".into()),
Utc::now(),
);
let mut draft = DraftPane::default();
named.apply_to_draft(&mut draft);
// A later batch must not overwrite the name the spawn was seeded with.
draft.seed_names(vec!["late-name".into()]);
assert_eq!(draft.name.value(), "amber-falcon");
assert_eq!(draft.branch.value(), "amber-falcon");
}
#[test]
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
let projects = vec![project("/repos/p", 0, Some("claude"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
draft.seed_names(vec!["late-name".into()]);
assert_eq!(draft.name.value(), "late-name");
}
#[test]
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
let mut draft = DraftPane::default();
r.apply_to_draft(&mut draft);
assert_eq!(draft.command(), "nvim -d");
assert!(draft.is_custom());
}
}
+259 -8
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,
}
@@ -111,7 +117,9 @@ impl InputKey {
if *alt {
out.push_str("alt-");
}
if *shift {
// Uppercase already implies shift; crossterm sets the flag
// on every such char, so skip the redundant prefix (#70).
if *shift && !c.is_uppercase() {
out.push_str("shift-");
}
out.push(*c);
@@ -120,11 +128,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 +149,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 +181,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 +199,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 +273,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 +308,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,14 +435,175 @@ 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"] {
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
let k = InputKey::parse(s).unwrap();
assert_eq!(k.canonical(), s, "round-trip {s:?}");
}
}
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
/// the uppercase letter already says "shifted"; a redundant prefix would
/// make the default `S` binding unreachable from the keyboard (#70).
#[test]
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
let k = InputKey::Char {
c: 'S',
ctrl: false,
alt: false,
shift: true,
};
assert_eq!(k.canonical(), "S");
}
#[test]
fn canonical_round_trips_specials() {
for s in [
@@ -377,12 +615,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)";
+44 -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)]
@@ -10,21 +7,27 @@ pub mod app;
pub mod catalog;
pub mod cli;
pub mod config;
pub mod draft;
pub mod emulators;
pub mod fast_forward;
pub mod input;
pub mod keymap;
pub mod overlays;
pub mod pane;
pub mod projects;
pub mod render;
pub mod session_task;
pub mod sidebar;
pub mod state;
pub mod tabs;
pub mod terminal;
pub mod text_input;
pub mod worktrees;
use std::path::PathBuf;
use std::time::Duration;
use maestro_protocol::DaemonClient;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
@@ -40,14 +43,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 +68,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 +107,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()))
}
+39 -227
View File
@@ -1,209 +1,41 @@
//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay,
//! transient toast.
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
//!
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
//! panes ([`crate::draft::DraftPane`]).
//!
//! All overlays are centered popups drawn over the rest of the UI; the
//! `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::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use ratatui::Frame;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SpawnField {
#[default]
Daemon,
Cwd,
Command,
Submit,
}
#[derive(Debug, Default, Clone)]
pub struct SpawnModal {
pub daemons: Vec<DaemonInfo>,
pub daemon_idx: usize,
pub cwd: String,
pub command: String,
pub focused_field: SpawnField,
}
impl SpawnModal {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Daemon => SpawnField::Cwd,
SpawnField::Cwd => SpawnField::Command,
SpawnField::Command => SpawnField::Submit,
SpawnField::Submit => SpawnField::Daemon,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Daemon => SpawnField::Submit,
SpawnField::Cwd => SpawnField::Daemon,
SpawnField::Command => SpawnField::Cwd,
SpawnField::Submit => SpawnField::Command,
};
}
/// Handle one keystroke while this modal owns input. Returns
/// [`SpawnInputResult::Submit`] when the user accepts; the caller fires
/// the POST and dismisses the modal.
pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult {
use crate::input::InputKey;
match key {
InputKey::Tab => {
self.next_field();
SpawnInputResult::Consumed
}
InputKey::Esc => SpawnInputResult::Cancel,
InputKey::Enter => match self.focused_field {
SpawnField::Submit => SpawnInputResult::Submit,
_ => {
self.next_field();
SpawnInputResult::Consumed
}
},
InputKey::Char { c, .. } => {
match self.focused_field {
SpawnField::Cwd => self.cwd.push(*c),
SpawnField::Command => self.command.push(*c),
SpawnField::Daemon => {
if !self.daemons.is_empty() {
match c {
'j' => {
self.daemon_idx = (self.daemon_idx + 1) % self.daemons.len();
}
'k' => {
self.daemon_idx = (self.daemon_idx + self.daemons.len() - 1)
% self.daemons.len();
}
_ => {}
}
}
}
SpawnField::Submit => {}
}
SpawnInputResult::Consumed
}
InputKey::Backspace => {
match self.focused_field {
SpawnField::Cwd => {
self.cwd.pop();
}
SpawnField::Command => {
self.command.pop();
}
_ => {}
}
SpawnInputResult::Consumed
}
_ => SpawnInputResult::Consumed,
}
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 60, 12);
f.render_widget(Clear, area);
let block = Block::default().title(" spawn ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
let highlight = |field: SpawnField| {
if self.focused_field == field {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
}
};
let daemon_label = self
.daemons
.get(self.daemon_idx)
.map(|d| d.hostname.as_str())
.unwrap_or("(none)");
let field_row = |label: &str, value: &str, style: Style| {
Paragraph::new(Line::from(vec![
ratatui::text::Span::raw(format!("{label:8} ")),
ratatui::text::Span::styled(value.to_string(), style),
]))
};
f.render_widget(
field_row("daemon:", daemon_label, highlight(SpawnField::Daemon)),
rows[0],
);
f.render_widget(
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
rows[1],
);
f.render_widget(
field_row("command:", &self.command, highlight(SpawnField::Command)),
rows[2],
);
f.render_widget(
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
"[ Spawn ]",
highlight(SpawnField::Submit),
)])),
rows[3],
);
// Place the cursor at the end of the currently-focused text field so
// the user can see where their typing lands. Label is fixed-width
// 8 chars + 1 space = column 9 inside the row.
let cursor_col_for = |value: &str| -> u16 {
inner
.x
.saturating_add(9)
.saturating_add(value.chars().count() as u16)
};
match self.focused_field {
SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[1].y)),
SpawnField::Command => {
f.set_cursor_position((cursor_col_for(&self.command), rows[2].y))
}
SpawnField::Daemon | SpawnField::Submit => {
// Non-text fields are indicated by the REVERSED style; no
// cursor placement.
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpawnInputResult {
Consumed,
Cancel,
Submit,
}
#[derive(Debug, Clone)]
pub struct KillConfirm {
pub label: String,
/// #83: what the daemon's watcher last saw in the worktree, phrased by
/// [`maestro_protocol::InTheWay`]'s Display — so "this will be refused"
/// is visible before the attempt. `None` while nothing blocks.
pub blockers: Option<String>,
}
impl KillConfirm {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 50, 5);
let area = centered(parent, 60, if self.blockers.is_some() { 6 } else { 5 });
f.render_widget(Clear, area);
let block = Block::default().title(" kill ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
Paragraph::new(vec![
Line::from(format!("Kill session {}?", self.label)),
Line::from(""),
Line::from("[y] yes [n] no"),
]),
inner,
);
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
if let Some(blockers) = &self.blockers {
// Same phrasing the reclaim confirmation uses for its own guard.
lines.push(Line::from(format!("Currently in the way: {blockers}")));
lines.push(Line::from("The kill will be refused unless forced."));
}
lines.push(Line::from(""));
lines.push(Line::from("[y] yes [n] no"));
f.render_widget(Paragraph::new(lines), inner);
}
}
@@ -255,7 +87,7 @@ impl Toast {
}
}
fn centered(parent: Rect, w: u16, h: u16) -> Rect {
pub(crate) fn centered(parent: Rect, w: u16, h: u16) -> Rect {
let w = w.min(parent.width);
let h = h.min(parent.height);
Rect {
@@ -290,47 +122,11 @@ mod tests {
out
}
#[test]
fn spawn_modal_renders_field_labels() {
let modal = SpawnModal {
daemons: vec![DaemonInfo {
daemon_id: "k".into(),
hostname: "konishi".into(),
version: "0".into(),
daemon_url: "ws://x".into(),
home_dir: String::new(),
}],
daemon_idx: 0,
cwd: "/x".into(),
command: "bash".into(),
focused_field: SpawnField::Cwd,
};
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
for s in ["daemon", "cwd", "command", "Spawn", "konishi", "/x", "bash"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
#[test]
fn spawn_modal_field_rotation() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Daemon);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Command);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Daemon);
modal.prev_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
}
#[test]
fn kill_confirm_renders_prompt() {
let modal = KillConfirm {
label: "session-7".into(),
blockers: None,
};
let rendered = render_to_string(60, 10, |f| modal.render(f, f.area()));
assert!(rendered.contains("Kill"));
@@ -339,16 +135,32 @@ mod tests {
assert!(rendered.contains("no"));
}
/// #83: when the worktree blocks, the confirm names it before the
/// operator commits — in `InTheWay`'s own words.
#[test]
fn kill_confirm_names_what_blocks() {
let modal = KillConfirm {
label: "session-7".into(),
blockers: Some("2 uncommitted files".into()),
};
let rendered = render_to_string(70, 10, |f| modal.render(f, f.area()));
assert!(
rendered.contains("Currently in the way: 2 uncommitted files"),
"{rendered}"
);
assert!(rendered.contains("refused"), "{rendered}");
}
#[test]
fn help_overlay_lists_lines() {
let help = HelpOverlay {
lines: vec![
"i EnterInsert".into(),
"s OpenSpawnModal".into(),
"s NewDraftPane".into(),
],
};
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
assert!(rendered.contains("EnterInsert"));
assert!(rendered.contains("OpenSpawnModal"));
assert!(rendered.contains("NewDraftPane"));
}
}
+216
View File
@@ -0,0 +1,216 @@
//! Pure helpers behind the draft pane's project picker (#64): staleness,
//! recency ordering, worktree grouping, and free-text filtering. Mirrors
//! `web/src/projectList.ts` so both clients order and dim identically — the
//! daemon reports the timestamps; what "stale" means is a client decision
//! (#54).
use chrono::{DateTime, TimeDelta, Utc};
use maestro_protocol::Project;
/// Projects whose last activity is older than this are dimmed and sort after
/// fresh ones — still reachable, visibly stale.
pub const STALE_AFTER_DAYS: i64 = 7;
/// True when a project has no usable activity timestamp, one in the future,
/// or its last activity is older than [`STALE_AFTER_DAYS`] days.
pub fn is_stale(project: &Project, now: DateTime<Utc>) -> bool {
match project.last_activity {
None => true,
Some(t) => {
let age = now.signed_duration_since(t);
age < TimeDelta::zero() || age > TimeDelta::days(STALE_AFTER_DAYS)
}
}
}
fn time_of(project: &Project) -> DateTime<Utc> {
project.last_activity.unwrap_or(DateTime::UNIX_EPOCH)
}
/// Fresh projects first, each block newest-activity-first; unknown or future
/// timestamps sort as stalest. Ties fall back to name, then path, so the
/// order is stable.
pub fn sort_projects(mut projects: Vec<Project>, now: DateTime<Utc>) -> Vec<Project> {
projects.sort_by(|a, b| {
let stale = is_stale(a, now).cmp(&is_stale(b, now));
stale
.then_with(|| time_of(b).cmp(&time_of(a)))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.path.cmp(&b.path))
});
projects
}
/// Substring filter over project name and full path, case-insensitive.
pub fn matches_filter(project: &Project, query: &str) -> bool {
let q = query.trim().to_lowercase();
if q.is_empty() {
return true;
}
project.name.to_lowercase().contains(&q)
|| project.path.to_string_lossy().to_lowercase().contains(&q)
}
/// One repository group: the main checkout followed by its linked worktrees.
/// A group's position follows its freshest member, since a worktree worked on
/// today should pull its (dormant) base repo up with it.
#[derive(Debug)]
pub struct ProjectGroup<'a> {
pub main: &'a Project,
pub worktrees: Vec<&'a Project>,
}
/// Group `sorted` projects by their base repository. A worktree nests under
/// its main checkout even when the main sorts later; a main checkout missing
/// from the list doesn't drop its worktrees.
pub fn group_projects(sorted: &[Project]) -> Vec<ProjectGroup<'_>> {
let mut members: Vec<(String, Vec<&Project>)> = Vec::new();
for p in sorted {
let key = p
.base_repo
.as_ref()
.unwrap_or(&p.path)
.to_string_lossy()
.into_owned();
match members.iter_mut().find(|(k, _)| *k == key) {
Some((_, list)) => list.push(p),
None => members.push((key, vec![p])),
}
}
members
.into_iter()
.map(|(key, mut list)| {
let main_pos = list
.iter()
.position(|p| p.path.to_string_lossy() == key && !p.is_worktree)
.unwrap_or(0);
let main = list.remove(main_pos);
// Keep the daemon's newest-first order within the group.
list.sort_by_key(|p| std::cmp::Reverse(time_of(p)));
ProjectGroup {
main,
worktrees: list,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn days_ago(days: i64) -> DateTime<Utc> {
Utc::now() - TimeDelta::days(days)
}
fn project(name: &str, path: &str, last_activity: Option<DateTime<Utc>>) -> Project {
Project {
path: PathBuf::from(path),
name: name.into(),
is_worktree: false,
base_repo: None,
last_activity,
last_command: None,
}
}
fn worktree(
name: &str,
path: &str,
base: &str,
last_activity: Option<DateTime<Utc>>,
) -> Project {
Project {
is_worktree: true,
base_repo: Some(PathBuf::from(base)),
..project(name, path, last_activity)
}
}
#[test]
fn unknown_and_future_activity_are_stale() {
let now = Utc::now();
assert!(is_stale(&project("x", "/x", None), now));
assert!(is_stale(
&project("x", "/x", Some(now + TimeDelta::hours(1))),
now
));
}
#[test]
fn activity_within_a_week_is_fresh_beyond_is_stale() {
let now = Utc::now();
assert!(!is_stale(&project("x", "/x", Some(days_ago(6))), now));
assert!(is_stale(&project("x", "/x", Some(days_ago(8))), now));
}
#[test]
fn fresh_sorts_before_stale_then_newest_first() {
let now = Utc::now();
let sorted = sort_projects(
vec![
project("old-fresh", "/a", Some(days_ago(5))),
project("stale", "/b", Some(days_ago(30))),
project("newest", "/c", Some(days_ago(1))),
project("unknown", "/d", None),
],
now,
);
let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["newest", "old-fresh", "stale", "unknown"]);
}
#[test]
fn ties_break_by_name_then_path() {
let now = Utc::now();
let t = Some(days_ago(2));
let sorted = sort_projects(vec![project("b", "/b", t), project("a", "/a", t)], now);
let names: Vec<&str> = sorted.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["a", "b"]);
}
#[test]
fn filter_matches_name_or_path_case_insensitively() {
let p = project("Maestro", "/home/u/tea/Maestro", None);
assert!(matches_filter(&p, "maes"));
assert!(matches_filter(&p, " tea "));
assert!(!matches_filter(&p, "zzz"));
assert!(matches_filter(&p, ""), "empty query matches everything");
}
#[test]
fn worktrees_nest_under_their_main_checkout() {
let now = Utc::now();
let main = project("maestro", "/repos/maestro", Some(days_ago(10)));
let wt = worktree("64-tui", "/repos/wt64", "/repos/maestro", Some(days_ago(1)));
let other = project("other", "/repos/other", Some(days_ago(2)));
let sorted = sort_projects(vec![main, wt, other], now);
let groups = group_projects(&sorted);
assert_eq!(groups.len(), 2);
assert_eq!(
groups[0].main.name, "maestro",
"fresh worktree pulls its repo up"
);
assert_eq!(groups[0].worktrees.len(), 1);
assert_eq!(groups[0].worktrees[0].name, "64-tui");
assert_eq!(groups[1].main.name, "other");
}
/// A main checkout absent from the scan must not drop its worktrees.
#[test]
fn worktrees_survive_a_missing_main_checkout() {
let now = Utc::now();
let wt = worktree(
"solo-wt",
"/repos/solo-wt",
"/repos/solo",
Some(days_ago(1)),
);
let sorted = sort_projects(vec![wt], now);
let groups = group_projects(&sorted);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].main.name, "solo-wt");
assert!(groups[0].worktrees.is_empty());
}
}
+99 -8
View File
@@ -10,11 +10,12 @@ use ratatui::Frame;
use crate::input::Region;
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::tabs::TabBar;
use crate::worktrees::{ReclaimConfirm, WorktreesView};
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
/// coordinate to one of these via [`RegionMap::region_at`].
@@ -63,9 +64,12 @@ pub struct RenderInputs<'a> {
pub sidebar: &'a Sidebar,
pub tab_bar: &'a TabBar,
pub pane: Option<&'a Pane>,
/// Inline spawn form when the active tab's pane is a draft (issue #63).
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub spawn: &'a SpawnModal,
pub help: &'a HelpOverlay,
/// Retained-worktree entries for the worktrees view (#85).
pub worktrees: Option<&'a WorktreesView>,
}
/// Draw one frame. Returns the `RegionMap` the input router will consult for
@@ -131,20 +135,30 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
if let Some(p) = inputs.pane {
p.render(f, pane_area);
}
if let Some(d) = inputs.draft {
d.render(f, pane_area, pane_focused);
}
map.pane = Some(pane_area);
// Status bar — always visible, never eclipsed by overlays so the user can
// always see the current mode + key hints.
render_status_bar(f, status_area, inputs.state);
match &inputs.state.overlay {
Some(OverlayKind::Spawn) => {
inputs.spawn.render(f, area);
map.modal = Some(area);
}
match inputs.state.overlay() {
Some(OverlayKind::KillConfirm(id)) => {
// #83: surface what the watcher last saw in the worktree, in
// InTheWay's own words, before the operator commits to the kill.
let blockers = inputs
.sidebar
.sessions
.iter()
.find(|s| &s.id == id)
.and_then(|s| s.blocked_work)
.filter(|b| !b.is_clear())
.map(|b| b.to_string());
let kc = KillConfirm {
label: id.to_string(),
blockers,
};
kc.render(f, area);
map.modal = Some(area);
@@ -153,6 +167,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
inputs.help.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Worktrees) => {
if let Some(view) = inputs.worktrees {
view.render(f, area);
map.modal = Some(area);
}
}
Some(OverlayKind::ReclaimConfirm {
branch, blockers, ..
}) => {
let rc = ReclaimConfirm {
branch: branch.clone(),
blockers: blockers.clone(),
};
rc.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Toast(text)) => {
let t = Toast { text: text.clone() };
t.render(f, area);
@@ -186,7 +216,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
Focus::Pane => "pane",
};
let hint = match state.mode {
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
Mode::Insert => "^a: back to normal",
};
let mode_style = match state.mode {
@@ -266,4 +296,65 @@ mod tests {
assert_eq!(map.region_at(15, 10), Region::Modal);
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
}
/// Issue #63: an empty pane is a draft — the frame must show the spawn
/// form where the terminal would otherwise be.
#[test]
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
use crate::overlays::HelpOverlay;
use crate::state::PaneRef;
use std::collections::HashSet;
let mut state = AppState::new();
state.open_draft_pane();
let draft = match &state.tabs[0].pane {
Some(PaneRef::Draft(d)) => d,
other => panic!("expected a draft pane, got {other:?}"),
};
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: None,
draft: Some(draft),
state: &state,
help: &help,
worktrees: None,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
for s in [
"draft",
"cmd:",
"dir:",
"name:",
"[ Spawn ]",
"Other directory…",
] {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
}

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