Implement cross-platform grid agent operator TUI (#128)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m47s
CI / required (push) Failing after 1m59s

This commit is contained in:
2026-08-18 10:15:46 +02:00
parent e99ee0f33d
commit 6370b3e416
10 changed files with 1351 additions and 18 deletions

View File

@@ -22,11 +22,25 @@ impl fmt::Display for CliError {
impl Error for CliError {}
fn color_disabled() -> bool {
std::env::var_os("NO_COLOR").is_some()
|| std::env::var_os("TERM").is_some_and(|value| value == "dumb")
}
#[derive(Clone, Copy, Default, Eq, PartialEq)]
enum Operation {
#[default]
Serve,
CheckConfig,
RunOnce,
Tui,
TuiClient,
}
#[derive(Default)]
struct Options {
config: Option<PathBuf>,
check_config: bool,
run_once: bool,
operation: Operation,
}
fn options() -> Result<Option<Options>, CliError> {
@@ -42,15 +56,19 @@ fn options() -> Result<Option<Options>, CliError> {
}
if argument == "--help" || argument == "-h" {
println!(
"metacrate-grid-agent [--config PATH] [--check-config | --run-once]\n\
"metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client]\n\
Configuration precedence: defaults < JSON < secret files < environment."
);
return Ok(None);
}
if argument == "--check-config" {
result.check_config = true;
set_operation(&mut result, Operation::CheckConfig)?;
} else if argument == "--run-once" {
result.run_once = true;
set_operation(&mut result, Operation::RunOnce)?;
} else if argument == "--tui" {
set_operation(&mut result, Operation::Tui)?;
} else if argument == "--tui-client" {
set_operation(&mut result, Operation::TuiClient)?;
} else if argument == "--config" {
let path = arguments
.next()
@@ -65,14 +83,17 @@ fn options() -> Result<Option<Options>, CliError> {
)));
}
}
if result.check_config && result.run_once {
return Err(CliError(
"--check-config and --run-once are mutually exclusive".into(),
));
}
Ok(Some(result))
}
fn set_operation(options: &mut Options, operation: Operation) -> Result<(), CliError> {
if options.operation != Operation::Serve {
return Err(CliError("operation flags are mutually exclusive".into()));
}
options.operation = operation;
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let Some(options) = options()? else {
@@ -83,13 +104,33 @@ async fn main() -> Result<(), Box<dyn Error>> {
loader = loader.with_file(path);
}
let config = loader.load()?;
if options.check_config {
if options.operation == Operation::CheckConfig {
println!("configuration is valid for {:?} mode", config.mode);
return Ok(());
}
if options.operation == Operation::TuiClient {
let token = config
.control
.operator_token
.as_ref()
.ok_or_else(|| CliError("split TUI requires a configured operator token".into()))?;
let client = metacrate_grid_agent::ReconnectingTcpTransport::new(
config.control.listen,
token.clone(),
config.control.limits,
);
metacrate_grid_agent::tui::run_terminal(std::sync::Arc::new(client), !color_disabled())
.await?;
return Ok(());
}
if config.mode != OperatingMode::OfflineFake {
#[cfg(feature = "live-grid")]
return run_live(config, options.run_once).await;
return run_live(
config,
options.operation == Operation::RunOnce,
options.operation == Operation::Tui,
)
.await;
#[cfg(not(feature = "live-grid"))]
return Err(CliError(
"live grid mode requires rebuilding with --features live-grid".into(),
@@ -97,9 +138,12 @@ async fn main() -> Result<(), Box<dyn Error>> {
.into());
}
if options.operation == Operation::Tui {
return Err(CliError("embedded TUI requires live-grid mode".into()).into());
}
let startup_timeout = config.timeouts.startup;
let mut handle = AgentService::offline(config)?.start()?;
if options.run_once {
if options.operation == Operation::RunOnce {
tokio::time::timeout(startup_timeout, async {
loop {
match handle.next_event().await {
@@ -145,6 +189,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
async fn run_live(
config: metacrate_grid_agent::AgentConfig,
run_once: bool,
tui: bool,
) -> Result<(), Box<dyn Error>> {
use metacrate_grid_agent::{
AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget,
@@ -248,7 +293,7 @@ async fn run_live(
control_target.attach_observability(live.observability.clone());
control_target.update_session(handle.status());
let erased_target: Arc<dyn ControlTarget> = control_target.clone();
let (control_plane, _integrated_client, control_server) = match config.mode {
let (control_plane, integrated_client, control_server) = match config.mode {
OperatingMode::Integrated => {
let (plane, client) = ControlPlane::integrated(erased_target, config.control.limits)?;
(plane, Some(client), None)
@@ -286,6 +331,18 @@ async fn run_live(
}
};
let _tui_task = if tui {
let client = integrated_client.ok_or_else(|| {
CliError("--tui requires integrated mode; use --tui-client for split mode".into())
})?;
Some(tokio::spawn(metacrate_grid_agent::tui::run_terminal(
Arc::new(client),
!color_disabled(),
)))
} else {
None
};
println!("grid agent session supervisor started; press Ctrl-C to stop");
let mut signal_error = None;
let mut control_failure = None;