This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-cli/src/dispatch.rs
T
naps62-yoloandClaude Opus 4.8 0df1d61f07 feat(maestro): rewire CLI through home-daemon broker with registry fallback (NAP-244)
`maestro list/spawn/kill/attach` now target the home daemon's local API
(`[cli].home_daemon_url`, default http://127.0.0.1:7070) instead of the
registry directly. The daemon brokers transport selection and `allow_inbound`
enforcement.

- list reads the daemon's catalog mirror (own ∪ peer sessions)
- spawn/kill hit the daemon's local session endpoints
- attach opens the daemon's `WS /api/attach/:id` broker (shared pump loop
  via the new `run_attach_url` / `broker_attach_url`)

Compat fallback to the legacy registry path (HTTP + direct WS) with a stderr
warning when the home daemon is unreachable. Cross-daemon spawn (501) and kill
(404) — not yet brokered locally — also fall back so they keep working.

Adds `[cli].home_daemon_url` config + `--home-daemon-url`/`MAESTRO_HOME_DAEMON_URL`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:23:17 +00:00

291 lines
9.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 enforces `allow_inbound` on the target side.
//!
//! 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. A few
//! operations the daemon can't yet satisfy locally (cross-daemon spawn → 501,
//! cross-daemon kill → 404) also fall back to the registry, which forwards
//! them the old way. The fallback is removed in a future ticket.
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, PolicyCmd, SetRegistryArgs, SpawnArgs};
use crate::config::{default_config_path, CliConfig, ConfigError};
use crate::http::{HttpError, RegistryClient};
use crate::policy::{run_reload as run_policy_reload, run_show as run_policy_show};
use crate::sessions::{fetch_connect_info, run_kill, run_list, run_spawn};
#[derive(Debug, thiserror::Error)]
#[allow(clippy::result_large_err)]
pub enum CliError {
#[error("config: {0}")]
Config(#[from] ConfigError),
#[error("{0}")]
Http(#[from] crate::http::HttpError),
#[error("{0}")]
Attach(#[from] crate::attach::AttachError),
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Policy(#[from] crate::policy::PolicyError),
}
/// Dispatch a parsed top-level subcommand. The umbrella binary passes the
/// global flags (`--config`, `--registry-url`, `--home-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>,
) -> Result<(), CliError> {
let config_path = match config {
Some(p) => p,
None => default_config_path()?,
};
match command {
Command::SetRegistry(args) => set_registry(&config_path, args),
Command::Policy(sub) => policy(&config_path, sub).await,
Command::List(args) => {
list(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
}
Command::Spawn(args) => {
spawn(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
}
Command::Attach(args) => {
attach(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
}
Command::Kill(args) => {
kill(
&config_path,
registry_url_override,
home_daemon_url_override,
args,
)
.await
}
}
}
#[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 policy(config_path: &Path, sub: PolicyCmd) -> Result<(), CliError> {
let mut stdout = std::io::stdout().lock();
match sub {
PolicyCmd::Reload => run_policy_reload(config_path, &mut stdout).await?,
PolicyCmd::Show { json } => run_policy_show(config_path, json, &mut stdout).await?,
}
Ok(())
}
#[allow(clippy::result_large_err)]
async fn list(
config_path: &Path,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
args: ListArgs,
) -> Result<(), CliError> {
let cfg = CliConfig::load(config_path)?;
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override);
let daemon = daemon_client(&home_url, config_path);
let mut stdout = std::io::stdout().lock();
match run_list(&daemon, &args, &mut stdout).await {
Ok(()) => Ok(()),
Err(e) if e.is_unreachable() => {
warn_fallback(&home_url, &e);
let registry = registry_client(registry_url_override, &cfg, config_path)?;
run_list(&registry, &args, &mut stdout).await?;
Ok(())
}
Err(e) => Err(e.into()),
}
}
#[allow(clippy::result_large_err)]
async fn spawn(
config_path: &Path,
registry_url_override: Option<String>,
home_daemon_url_override: Option<String>,
args: SpawnArgs,
) -> Result<(), CliError> {
let cfg = CliConfig::load(config_path)?;
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override.clone());
let daemon = daemon_client(&home_url, config_path);
let attach_after = args.attach;
// `spawn --attach` has no detach-key flag; use the attach default (Ctrl-q).
let detach_key = 0x11;
// Cross-daemon create isn't brokered by the local daemon yet (501), so we
// fall back to the registry on that as well as on unreachable.
let session = match run_spawn(&daemon, args.clone()).await {
Ok(s) => s,
Err(e) if e.is_unreachable() || e.is_remote_op_unsupported() => {
warn_fallback(&home_url, &e);
let registry = registry_client(registry_url_override.clone(), &cfg, config_path)?;
run_spawn(&registry, args).await?
}
Err(e) => return Err(e.into()),
};
println!("spawned {}", session.id);
if attach_after {
attach_session(
&home_url,
registry_url_override,
&cfg,
config_path,
session.id,
detach_key,
)
.await?;
}
Ok(())
}
#[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 local daemon only kills sessions it owns (peer sessions → 404); the
// registry forwards cross-daemon kills, so fall back on 404 too.
match run_kill(&daemon, &args).await {
Ok(()) => {}
Err(e) if e.is_unreachable() || e.is_not_found() => {
warn_fallback(&home_url, &e);
let registry = registry_client(registry_url_override, &cfg, config_path)?;
run_kill(&registry, &args).await?;
}
Err(e) => return Err(e.into()),
}
println!("killed {}", args.session_id);
Ok(())
}
/// Attach to `session_id` through the home-daemon broker, falling back to the
/// registry's connect-info + direct WS path if the daemon is unreachable.
#[allow(clippy::result_large_err)]
async fn attach_session(
home_url: &str,
registry_url_override: Option<String>,
cfg: &CliConfig,
config_path: &Path,
session_id: uuid::Uuid,
detach: u8,
) -> Result<(), CliError> {
let broker_url = broker_attach_url(home_url, session_id)?;
match run_attach_url(&broker_url, detach).await {
Ok(()) => Ok(()),
Err(e) if e.is_unreachable() => {
eprintln!(
"[maestro] home daemon at {home_url} unreachable; \
falling back to the registry (legacy direct attach)"
);
let registry = registry_client(registry_url_override, cfg, config_path)?;
let info = fetch_connect_info(&registry, session_id).await?;
run_attach(info, session_id, detach).await?;
Ok(())
}
Err(e) => Err(e.into()),
}
}
/// HTTP client pointed at the home daemon's local API. The daemon's local
/// surface needs no auth (same-trust as `bind_addr`), so no cookie is sent.
fn daemon_client(home_url: &str, config_path: &Path) -> RegistryClient {
RegistryClient::new(home_url.to_string(), None, config_path.to_path_buf())
}
/// HTTP client pointed at the registry (legacy / fallback path).
#[allow(clippy::result_large_err)]
fn registry_client(
override_url: Option<String>,
cfg: &CliConfig,
config_path: &Path,
) -> Result<RegistryClient, CliError> {
let url = override_url
.or_else(|| cfg.registry_url.clone())
.ok_or(ConfigError::NoRegistryUrl)?;
Ok(RegistryClient::new(
url,
cfg.cookie.clone(),
config_path.to_path_buf(),
))
}
fn warn_fallback(home_url: &str, err: &HttpError) {
eprintln!(
"[maestro] home daemon at {home_url} unavailable ({err}); \
falling back to the registry"
);
}