`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>
291 lines
9.7 KiB
Rust
291 lines
9.7 KiB
Rust
//! 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(®istry, &args, &mut stdout).await?;
|
||
Ok(())
|
||
}
|
||
Err(e) => Err(e.into()),
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::result_large_err)]
|
||
async fn spawn(
|
||
config_path: &Path,
|
||
registry_url_override: Option<String>,
|
||
home_daemon_url_override: Option<String>,
|
||
args: SpawnArgs,
|
||
) -> Result<(), CliError> {
|
||
let cfg = CliConfig::load(config_path)?;
|
||
let home_url = cfg.resolve_home_daemon_url(home_daemon_url_override.clone());
|
||
let daemon = daemon_client(&home_url, config_path);
|
||
let attach_after = args.attach;
|
||
// `spawn --attach` has no detach-key flag; use the attach default (Ctrl-q).
|
||
let detach_key = 0x11;
|
||
|
||
// 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(®istry, args).await?
|
||
}
|
||
Err(e) => return Err(e.into()),
|
||
};
|
||
println!("spawned {}", session.id);
|
||
|
||
if attach_after {
|
||
attach_session(
|
||
&home_url,
|
||
registry_url_override,
|
||
&cfg,
|
||
config_path,
|
||
session.id,
|
||
detach_key,
|
||
)
|
||
.await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[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(®istry, &args).await?;
|
||
}
|
||
Err(e) => return Err(e.into()),
|
||
}
|
||
println!("killed {}", args.session_id);
|
||
Ok(())
|
||
}
|
||
|
||
/// Attach to `session_id` through the home-daemon broker, falling back to the
|
||
/// registry's connect-info + direct WS path if the daemon is unreachable.
|
||
#[allow(clippy::result_large_err)]
|
||
async fn attach_session(
|
||
home_url: &str,
|
||
registry_url_override: Option<String>,
|
||
cfg: &CliConfig,
|
||
config_path: &Path,
|
||
session_id: uuid::Uuid,
|
||
detach: u8,
|
||
) -> Result<(), CliError> {
|
||
let broker_url = broker_attach_url(home_url, session_id)?;
|
||
match run_attach_url(&broker_url, detach).await {
|
||
Ok(()) => Ok(()),
|
||
Err(e) if e.is_unreachable() => {
|
||
eprintln!(
|
||
"[maestro] home daemon at {home_url} unreachable; \
|
||
falling back to the registry (legacy direct attach)"
|
||
);
|
||
let registry = registry_client(registry_url_override, cfg, config_path)?;
|
||
let info = fetch_connect_info(®istry, session_id).await?;
|
||
run_attach(info, session_id, detach).await?;
|
||
Ok(())
|
||
}
|
||
Err(e) => Err(e.into()),
|
||
}
|
||
}
|
||
|
||
/// HTTP client pointed at the home daemon's local API. The daemon's local
|
||
/// surface needs no auth (same-trust as `bind_addr`), so no cookie is sent.
|
||
fn daemon_client(home_url: &str, config_path: &Path) -> RegistryClient {
|
||
RegistryClient::new(home_url.to_string(), None, config_path.to_path_buf())
|
||
}
|
||
|
||
/// HTTP client pointed at the registry (legacy / fallback path).
|
||
#[allow(clippy::result_large_err)]
|
||
fn registry_client(
|
||
override_url: Option<String>,
|
||
cfg: &CliConfig,
|
||
config_path: &Path,
|
||
) -> Result<RegistryClient, CliError> {
|
||
let url = override_url
|
||
.or_else(|| cfg.registry_url.clone())
|
||
.ok_or(ConfigError::NoRegistryUrl)?;
|
||
Ok(RegistryClient::new(
|
||
url,
|
||
cfg.cookie.clone(),
|
||
config_path.to_path_buf(),
|
||
))
|
||
}
|
||
|
||
fn warn_fallback(home_url: &str, err: &HttpError) {
|
||
eprintln!(
|
||
"[maestro] home daemon at {home_url} unavailable ({err}); \
|
||
falling back to the registry"
|
||
);
|
||
}
|