Implement cross-platform grid agent operator TUI (#128)
This commit is contained in:
133
Cargo.lock
generated
133
Cargo.lock
generated
@@ -554,7 +554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
|
||||
dependencies = [
|
||||
"termcolor",
|
||||
"unicode-width",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -591,6 +591,15 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -718,6 +727,33 @@ version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
@@ -841,6 +877,28 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -922,6 +980,15 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
@@ -2105,6 +2172,12 @@ 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"
|
||||
@@ -2198,6 +2271,7 @@ dependencies = [
|
||||
name = "metacrate-grid-agent"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"libremetaverse",
|
||||
"libremetaverse-types",
|
||||
"reqwest",
|
||||
@@ -2207,6 +2281,7 @@ dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"unicode-width 0.2.2",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -2249,6 +2324,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -3521,6 +3597,27 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -4146,12 +4243,24 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -4394,6 +4503,22 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -4403,6 +4528,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
"commands": [
|
||||
{
|
||||
"label": "Cross-check portable crates for Windows GNU",
|
||||
"args": ["check", "--locked", "-j", "1", "--target", "x86_64-pc-windows-gnu", "--all-targets", "--no-default-features", "--features", "libremetaverse-imaging/rust-j2k,libremetaverse-imaging-skia/rust-skia", "-p", "libremetaverse-types", "-p", "libremetaverse-structured-data", "-p", "libremetaverse-imaging", "-p", "libremetaverse-imaging-skia", "-p", "libremetaverse-prim-mesher", "-p", "libremetaverse-lsl-tools", "-p", "libremetaverse-rlv", "-p", "metacrate-ci-matrix"]
|
||||
"args": ["check", "--locked", "-j", "1", "--target", "x86_64-pc-windows-gnu", "--all-targets", "--no-default-features", "--features", "libremetaverse-imaging/rust-j2k,libremetaverse-imaging-skia/rust-skia", "-p", "libremetaverse-types", "-p", "libremetaverse-structured-data", "-p", "libremetaverse-imaging", "-p", "libremetaverse-imaging-skia", "-p", "libremetaverse-prim-mesher", "-p", "libremetaverse-lsl-tools", "-p", "libremetaverse-rlv", "-p", "metacrate-grid-agent", "-p", "metacrate-ci-matrix"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "Bounded pure-Rust OpenSim grid-agent service foundation"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
crossterm = "0.29"
|
||||
libremetaverse = { version = "0.0.1", path = "../libremetaverse", default-features = false, optional = true }
|
||||
libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] }
|
||||
@@ -19,6 +20,7 @@ sha2 = "0.11"
|
||||
tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] }
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = ["aws_lc_rs", "tls12"] }
|
||||
url = "2.5.8"
|
||||
unicode-width = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53.1", features = ["test-util"] }
|
||||
|
||||
@@ -61,3 +61,6 @@ The unified event schema, pseudonymous correlations, bounded JSONL rotation,
|
||||
fixed-cardinality metrics, diagnostic-capture warning, and non-executing replay
|
||||
contract are specified in
|
||||
[`../../docs/grid-agent-observability.md`](../../docs/grid-agent-observability.md).
|
||||
The embedded/split cross-platform operator interface, keyboard controls,
|
||||
privacy boundary, and focused gates are documented in
|
||||
[`../../docs/grid-agent-tui.md`](../../docs/grid-agent-tui.md).
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod policy;
|
||||
pub mod service;
|
||||
pub mod session;
|
||||
pub mod tool_loop;
|
||||
pub mod tui;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -38,6 +39,8 @@ mod perception_tests;
|
||||
mod policy_tests;
|
||||
#[cfg(test)]
|
||||
mod session_tests;
|
||||
#[cfg(test)]
|
||||
mod tui_tests;
|
||||
|
||||
pub use backend::{
|
||||
AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend,
|
||||
@@ -124,6 +127,11 @@ pub use tool_loop::{
|
||||
HistorySummarizer, SessionGeneration, ToolExecution, ToolExecutor, ToolFuture, ToolLoop,
|
||||
ToolLoopError, ToolLoopLimits, ToolLoopOutcome,
|
||||
};
|
||||
pub use tui::{
|
||||
CommandConfirmation, EventFilter, OperatorCommand, OperatorScreen, OperatorSnapshot,
|
||||
OperatorTui, ReconnectingTcpTransport, TuiAction, TuiError, TuiInput, TuiRenderOptions,
|
||||
TuiTransport,
|
||||
};
|
||||
pub use types::{
|
||||
BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage,
|
||||
GridEvent, GridEventKind, LlmRequest, LlmResult, MessageRole, ObservableEvent,
|
||||
|
||||
@@ -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;
|
||||
|
||||
865
crates/metacrate-grid-agent/src/tui.rs
Normal file
865
crates/metacrate-grid-agent/src/tui.rs
Normal file
@@ -0,0 +1,865 @@
|
||||
//! Cross-platform, transport-neutral terminal operator interface.
|
||||
//!
|
||||
//! The reducer and renderer are terminal-free. [`run_terminal`] is the only
|
||||
//! code which touches a terminal and uses crossterm's Windows/Unix backends.
|
||||
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
|
||||
use crate::control_plane::{
|
||||
AuditEventView, CONTROL_PROTOCOL_VERSION, ControlEvent, ControlEventKind, ControlPayload,
|
||||
ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, HealthView,
|
||||
InProcessControlClient, Page, PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView,
|
||||
SessionMetadataView, TcpControlClient,
|
||||
};
|
||||
use crate::observability::{EventSeverity, MetricsSnapshot, StructuredEvent};
|
||||
use crate::{ControlLimits, SecretString};
|
||||
use crossterm::{cursor, event, execute, queue, style, terminal};
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::io::{self, Write};
|
||||
use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
const MAX_TIMELINE: usize = 2_000;
|
||||
const MAX_ERRORS: usize = 100;
|
||||
const PAGE_LIMIT: u16 = 100;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OperatorScreen {
|
||||
Overview,
|
||||
Sessions,
|
||||
QueuesAndBudgets,
|
||||
Roaming,
|
||||
Approvals,
|
||||
Timeline,
|
||||
Health,
|
||||
Errors,
|
||||
Diagnostics,
|
||||
}
|
||||
|
||||
impl OperatorScreen {
|
||||
const ALL: [Self; 9] = [
|
||||
Self::Overview,
|
||||
Self::Sessions,
|
||||
Self::QueuesAndBudgets,
|
||||
Self::Roaming,
|
||||
Self::Approvals,
|
||||
Self::Timeline,
|
||||
Self::Health,
|
||||
Self::Errors,
|
||||
Self::Diagnostics,
|
||||
];
|
||||
|
||||
const fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Overview => "Overview",
|
||||
Self::Sessions => "Sessions",
|
||||
Self::QueuesAndBudgets => "Queues & budgets",
|
||||
Self::Roaming => "Roaming",
|
||||
Self::Approvals => "Approvals",
|
||||
Self::Timeline => "Timeline",
|
||||
Self::Health => "Health & metrics",
|
||||
Self::Errors => "Recent errors",
|
||||
Self::Diagnostics => "Diagnostics",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct EventFilter {
|
||||
pub query: String,
|
||||
pub severity: Option<EventSeverity>,
|
||||
pub component: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub session: Option<String>,
|
||||
pub action: Option<String>,
|
||||
pub since_unix_millis: Option<u64>,
|
||||
}
|
||||
|
||||
impl EventFilter {
|
||||
fn matches(&self, item: &StructuredEvent) -> bool {
|
||||
if self.severity.is_some_and(|value| value != item.severity)
|
||||
|| self
|
||||
.component
|
||||
.as_ref()
|
||||
.is_some_and(|value| value != &item.component)
|
||||
|| self
|
||||
.since_unix_millis
|
||||
.is_some_and(|value| item.unix_millis < value)
|
||||
|| self
|
||||
.avatar
|
||||
.as_ref()
|
||||
.is_some_and(|value| item.correlation.avatar_id.as_ref() != Some(value))
|
||||
|| self
|
||||
.session
|
||||
.as_ref()
|
||||
.is_some_and(|value| item.correlation.session_id.as_ref() != Some(value))
|
||||
|| self
|
||||
.action
|
||||
.as_ref()
|
||||
.is_some_and(|value| item.correlation.action_id.as_ref() != Some(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let query = self.query.to_lowercase();
|
||||
query.is_empty()
|
||||
|| item.component.to_lowercase().contains(&query)
|
||||
|| item.family.to_lowercase().contains(&query)
|
||||
|| item
|
||||
.result_code
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.to_lowercase().contains(&query))
|
||||
|| item
|
||||
.reason_code
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.to_lowercase().contains(&query))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OperatorSnapshot {
|
||||
pub health: Option<HealthView>,
|
||||
pub runtime: Option<RuntimeView>,
|
||||
pub metrics: Option<MetricsSnapshot>,
|
||||
pub sessions: Vec<SessionMetadataView>,
|
||||
pub schedules: Vec<ScheduledJobView>,
|
||||
pub approvals: Vec<PendingApprovalView>,
|
||||
pub audit: Vec<AuditEventView>,
|
||||
pub timeline: VecDeque<StructuredEvent>,
|
||||
pub gap_count: u64,
|
||||
pub last_sequence: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for OperatorSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
health: None,
|
||||
runtime: None,
|
||||
metrics: None,
|
||||
sessions: Vec::new(),
|
||||
schedules: Vec::new(),
|
||||
approvals: Vec::new(),
|
||||
audit: Vec::new(),
|
||||
timeline: VecDeque::with_capacity(MAX_TIMELINE),
|
||||
gap_count: 0,
|
||||
last_sequence: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum OperatorCommand {
|
||||
Pause,
|
||||
Resume,
|
||||
CancelAction(String),
|
||||
DecideApproval { id: u64, approve: bool },
|
||||
Reconnect,
|
||||
ExpireSession { avatar_id: String, direct_im: bool },
|
||||
ToggleSchedule { job_id: String, enabled: bool },
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
impl OperatorCommand {
|
||||
fn request(&self) -> ControlRequest {
|
||||
match self {
|
||||
Self::Pause => ControlRequest::PauseAutonomy,
|
||||
Self::Resume => ControlRequest::ResumeAutonomy,
|
||||
Self::CancelAction(action_id) => ControlRequest::CancelAction {
|
||||
action_id: action_id.clone(),
|
||||
},
|
||||
Self::DecideApproval { id, approve } => ControlRequest::DecideApproval {
|
||||
approval_id: *id,
|
||||
approve: *approve,
|
||||
},
|
||||
Self::Reconnect => ControlRequest::ForceReconnect,
|
||||
Self::ExpireSession {
|
||||
avatar_id,
|
||||
direct_im,
|
||||
} => ControlRequest::ExpireConversation {
|
||||
avatar_id: avatar_id.clone(),
|
||||
channel: if *direct_im {
|
||||
crate::control_plane::ConversationChannelView::DirectIm
|
||||
} else {
|
||||
crate::control_plane::ConversationChannelView::PublicChat
|
||||
},
|
||||
},
|
||||
Self::ToggleSchedule { job_id, enabled } => ControlRequest::SetRoamingJob {
|
||||
job_id: job_id.clone(),
|
||||
enabled: *enabled,
|
||||
},
|
||||
Self::Shutdown => ControlRequest::GracefulShutdown,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn confirmation(&self) -> CommandConfirmation {
|
||||
match self {
|
||||
Self::Pause | Self::Resume => CommandConfirmation::None,
|
||||
Self::CancelAction(_)
|
||||
| Self::DecideApproval { .. }
|
||||
| Self::Reconnect
|
||||
| Self::ExpireSession { .. }
|
||||
| Self::ToggleSchedule { .. } => CommandConfirmation::Normal,
|
||||
Self::Shutdown => CommandConfirmation::HighRisk,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CommandConfirmation {
|
||||
None,
|
||||
Normal,
|
||||
HighRisk,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum TuiInput {
|
||||
NextScreen,
|
||||
PreviousScreen,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
Refresh,
|
||||
Command(OperatorCommand),
|
||||
Confirm,
|
||||
Reject,
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum TuiAction {
|
||||
None,
|
||||
Refresh,
|
||||
Execute(OperatorCommand),
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TuiError(pub String);
|
||||
impl fmt::Display for TuiError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for TuiError {}
|
||||
|
||||
pub type TuiFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Result<ControlResponseEnvelope, TuiError>> + Send + 'a>>;
|
||||
|
||||
/// Common authenticated client boundary for embedded and split operation.
|
||||
pub trait TuiTransport: Send + Sync {
|
||||
fn request(&self, request: ControlRequestEnvelope) -> TuiFuture<'_>;
|
||||
}
|
||||
|
||||
impl TuiTransport for InProcessControlClient {
|
||||
fn request(&self, request: ControlRequestEnvelope) -> TuiFuture<'_> {
|
||||
Box::pin(async move { Ok(InProcessControlClient::request(self, request).await) })
|
||||
}
|
||||
}
|
||||
|
||||
impl TuiTransport for TcpControlClient {
|
||||
fn request(&self, request: ControlRequestEnvelope) -> TuiFuture<'_> {
|
||||
Box::pin(async move {
|
||||
TcpControlClient::request(self, request)
|
||||
.await
|
||||
.map_err(|e| TuiError(e.to_string()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Loopback split-mode transport which reconnects after a service restart.
|
||||
/// The capability is held in the redacting secret wrapper and never appears in
|
||||
/// diagnostics. Requests are retried once only when the transport itself died;
|
||||
/// application errors are not replayed.
|
||||
pub struct ReconnectingTcpTransport {
|
||||
address: SocketAddr,
|
||||
token: SecretString,
|
||||
limits: ControlLimits,
|
||||
client: tokio::sync::Mutex<Option<TcpControlClient>>,
|
||||
}
|
||||
|
||||
impl ReconnectingTcpTransport {
|
||||
#[must_use]
|
||||
pub fn new(address: SocketAddr, token: SecretString, limits: ControlLimits) -> Self {
|
||||
Self {
|
||||
address,
|
||||
token,
|
||||
limits,
|
||||
client: tokio::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ReconnectingTcpTransport {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ReconnectingTcpTransport")
|
||||
.field("address", &self.address)
|
||||
.field("token", &"[REDACTED]")
|
||||
.field("limits", &self.limits)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl TuiTransport for ReconnectingTcpTransport {
|
||||
fn request(&self, request: ControlRequestEnvelope) -> TuiFuture<'_> {
|
||||
Box::pin(async move {
|
||||
let mut client = self.client.lock().await;
|
||||
if client.is_none() {
|
||||
*client = Some(
|
||||
TcpControlClient::connect(
|
||||
self.address,
|
||||
self.token.expose_secret(),
|
||||
self.limits,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| TuiError(error.to_string()))?,
|
||||
);
|
||||
}
|
||||
let result = client
|
||||
.as_ref()
|
||||
.expect("client initialized")
|
||||
.request(request.clone())
|
||||
.await;
|
||||
match result {
|
||||
Ok(response) => Ok(response),
|
||||
Err(error) if error.retryable => {
|
||||
*client = None;
|
||||
let replacement = TcpControlClient::connect(
|
||||
self.address,
|
||||
self.token.expose_secret(),
|
||||
self.limits,
|
||||
)
|
||||
.await
|
||||
.map_err(|connect| TuiError(connect.to_string()))?;
|
||||
let response = replacement
|
||||
.request(request)
|
||||
.await
|
||||
.map_err(|retry| TuiError(retry.to_string()))?;
|
||||
*client = Some(replacement);
|
||||
Ok(response)
|
||||
}
|
||||
Err(error) => Err(TuiError(error.to_string())),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct TuiRenderOptions {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub color: bool,
|
||||
}
|
||||
|
||||
pub struct OperatorTui {
|
||||
pub snapshot: OperatorSnapshot,
|
||||
pub screen: OperatorScreen,
|
||||
pub filter: EventFilter,
|
||||
pending: Option<OperatorCommand>,
|
||||
request_number: u64,
|
||||
scroll: usize,
|
||||
status: String,
|
||||
errors: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl Default for OperatorTui {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
snapshot: OperatorSnapshot::default(),
|
||||
screen: OperatorScreen::Overview,
|
||||
filter: EventFilter::default(),
|
||||
pending: None,
|
||||
request_number: 1,
|
||||
scroll: 0,
|
||||
status: "connecting".into(),
|
||||
errors: VecDeque::with_capacity(MAX_ERRORS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OperatorTui {
|
||||
#[must_use]
|
||||
pub fn command_shortcut(&self, key: char) -> Option<OperatorCommand> {
|
||||
match key {
|
||||
'p' => Some(OperatorCommand::Pause),
|
||||
'u' => Some(OperatorCommand::Resume),
|
||||
'f' => Some(OperatorCommand::Reconnect),
|
||||
'a' | 'd' => {
|
||||
self.snapshot
|
||||
.approvals
|
||||
.first()
|
||||
.map(|approval| OperatorCommand::DecideApproval {
|
||||
id: approval.approval_id,
|
||||
approve: key == 'a',
|
||||
})
|
||||
}
|
||||
'x' => self.snapshot.timeline.iter().rev().find_map(|event| {
|
||||
event
|
||||
.correlation
|
||||
.action_id
|
||||
.clone()
|
||||
.map(OperatorCommand::CancelAction)
|
||||
}),
|
||||
'e' => self
|
||||
.snapshot
|
||||
.sessions
|
||||
.first()
|
||||
.map(|session| OperatorCommand::ExpireSession {
|
||||
avatar_id: session.avatar_id.clone(),
|
||||
direct_im: session.channel == "direct_im",
|
||||
}),
|
||||
't' => self
|
||||
.snapshot
|
||||
.schedules
|
||||
.first()
|
||||
.map(|job| OperatorCommand::ToggleSchedule {
|
||||
job_id: job.job_id.clone(),
|
||||
enabled: !job.enabled,
|
||||
}),
|
||||
's' => Some(OperatorCommand::Shutdown),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reduce(&mut self, input: TuiInput) -> TuiAction {
|
||||
match input {
|
||||
TuiInput::NextScreen => {
|
||||
let i = OperatorScreen::ALL
|
||||
.iter()
|
||||
.position(|v| *v == self.screen)
|
||||
.unwrap_or(0);
|
||||
self.screen = OperatorScreen::ALL[(i + 1) % OperatorScreen::ALL.len()];
|
||||
self.scroll = 0;
|
||||
TuiAction::None
|
||||
}
|
||||
TuiInput::PreviousScreen => {
|
||||
let i = OperatorScreen::ALL
|
||||
.iter()
|
||||
.position(|v| *v == self.screen)
|
||||
.unwrap_or(0);
|
||||
self.screen = OperatorScreen::ALL
|
||||
[(i + OperatorScreen::ALL.len() - 1) % OperatorScreen::ALL.len()];
|
||||
self.scroll = 0;
|
||||
TuiAction::None
|
||||
}
|
||||
TuiInput::ScrollUp => {
|
||||
self.scroll = self.scroll.saturating_sub(1);
|
||||
TuiAction::None
|
||||
}
|
||||
TuiInput::ScrollDown => {
|
||||
self.scroll = self.scroll.saturating_add(1);
|
||||
TuiAction::None
|
||||
}
|
||||
TuiInput::Refresh => TuiAction::Refresh,
|
||||
TuiInput::Quit => TuiAction::Exit,
|
||||
TuiInput::Command(command) if command.confirmation() == CommandConfirmation::None => {
|
||||
TuiAction::Execute(command)
|
||||
}
|
||||
TuiInput::Command(command) => {
|
||||
self.status = format!("confirm {:?} (y/n)", command.confirmation());
|
||||
self.pending = Some(command);
|
||||
TuiAction::None
|
||||
}
|
||||
TuiInput::Confirm => self
|
||||
.pending
|
||||
.take()
|
||||
.map_or(TuiAction::None, TuiAction::Execute),
|
||||
TuiInput::Reject => {
|
||||
self.pending = None;
|
||||
self.status = "command cancelled".into();
|
||||
TuiAction::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ingest_event(&mut self, event: ControlEvent) {
|
||||
self.snapshot.last_sequence = Some(event.sequence);
|
||||
match event.event {
|
||||
ControlEventKind::Gap {
|
||||
first_available,
|
||||
last_missed,
|
||||
} => {
|
||||
self.snapshot.gap_count = self.snapshot.gap_count.saturating_add(
|
||||
last_missed
|
||||
.saturating_sub(first_available)
|
||||
.saturating_add(1),
|
||||
);
|
||||
self.status = format!("event gap before {first_available}");
|
||||
}
|
||||
ControlEventKind::Mutation {
|
||||
operation, outcome, ..
|
||||
} => self.status = format!("{operation}: {outcome}"),
|
||||
ControlEventKind::StateChanged { component, state } => {
|
||||
self.status = format!("{component}: {state}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh(&mut self, client: &dyn TuiTransport) -> Result<(), TuiError> {
|
||||
let requests = [
|
||||
ControlRequest::Health,
|
||||
ControlRequest::Runtime,
|
||||
ControlRequest::Metrics,
|
||||
ControlRequest::ListSessions { page: page() },
|
||||
ControlRequest::ListScheduledJobs { page: page() },
|
||||
ControlRequest::ListPendingApprovals { page: page() },
|
||||
ControlRequest::ListAuditEvents { page: page() },
|
||||
ControlRequest::ListObservabilityEvents { page: page() },
|
||||
];
|
||||
for request in requests {
|
||||
self.send_and_apply(client, request).await?;
|
||||
}
|
||||
self.status = "connected".into();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&mut self,
|
||||
client: &dyn TuiTransport,
|
||||
command: OperatorCommand,
|
||||
) -> Result<(), TuiError> {
|
||||
self.send_and_apply(client, command.request()).await?;
|
||||
self.refresh(client).await
|
||||
}
|
||||
|
||||
async fn send_and_apply(
|
||||
&mut self,
|
||||
client: &dyn TuiTransport,
|
||||
request: ControlRequest,
|
||||
) -> Result<(), TuiError> {
|
||||
let envelope = ControlRequestEnvelope {
|
||||
version: CONTROL_PROTOCOL_VERSION,
|
||||
request_id: format!("tui-{}", self.request_number),
|
||||
request,
|
||||
};
|
||||
self.request_number = self.request_number.saturating_add(1);
|
||||
let response = client.request(envelope).await?;
|
||||
match response.result {
|
||||
Ok(payload) => self.apply(payload),
|
||||
Err(error) => {
|
||||
let message = format!("{:?}: {}", error.code, error.message);
|
||||
self.record_error(message.clone());
|
||||
return Err(TuiError(message));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply(&mut self, payload: ControlPayload) {
|
||||
match payload {
|
||||
ControlPayload::Health(value) => self.snapshot.health = Some(value),
|
||||
ControlPayload::Metrics(value) => self.snapshot.metrics = Some(value),
|
||||
ControlPayload::Runtime(value) => self.snapshot.runtime = Some(value),
|
||||
ControlPayload::Sessions(Page { items, .. }) => self.snapshot.sessions = items,
|
||||
ControlPayload::ScheduledJobs(Page { items, .. }) => self.snapshot.schedules = items,
|
||||
ControlPayload::PendingApprovals(Page { items, .. }) => self.snapshot.approvals = items,
|
||||
ControlPayload::AuditEvents(Page { items, .. }) => self.snapshot.audit = items,
|
||||
ControlPayload::ObservabilityEvents(Page { items, .. }) => {
|
||||
for event in items {
|
||||
if self.snapshot.timeline.len() == MAX_TIMELINE {
|
||||
self.snapshot.timeline.pop_front();
|
||||
}
|
||||
self.snapshot.timeline.push_back(event);
|
||||
}
|
||||
}
|
||||
ControlPayload::Accepted { operation_id } => {
|
||||
self.status = format!("accepted {operation_id}");
|
||||
}
|
||||
ControlPayload::Completed => self.status = "completed".into(),
|
||||
ControlPayload::Subscribed { current_sequence } => {
|
||||
self.snapshot.last_sequence = Some(current_sequence);
|
||||
}
|
||||
ControlPayload::Cancelled { request_id } => {
|
||||
self.status = format!("cancelled {request_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_error(&mut self, message: String) {
|
||||
if self.errors.len() == MAX_ERRORS {
|
||||
self.errors.pop_front();
|
||||
}
|
||||
self.errors.push_back(message);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn render(&self, options: TuiRenderOptions) -> String {
|
||||
let width = usize::from(options.width.max(20));
|
||||
let height = usize::from(options.height.max(5));
|
||||
let mut lines = vec![
|
||||
format!(
|
||||
"MetaCrate agent | {} | {}",
|
||||
self.screen.title(),
|
||||
self.status
|
||||
),
|
||||
format!(
|
||||
"[Tab] next [r] refresh [p] pause [u] resume [q] quit | gaps {}",
|
||||
self.snapshot.gap_count
|
||||
),
|
||||
];
|
||||
match self.screen {
|
||||
OperatorScreen::Overview => render_overview(&self.snapshot, &mut lines),
|
||||
OperatorScreen::Sessions => {
|
||||
lines.push("active sessions (metadata only)".into());
|
||||
for v in &self.snapshot.sessions {
|
||||
lines.push(format!(
|
||||
"{} {} {} turns={} bytes={}",
|
||||
v.session_id, v.avatar_id, v.channel, v.turns, v.bytes
|
||||
));
|
||||
}
|
||||
}
|
||||
OperatorScreen::QueuesAndBudgets => render_queues(&self.snapshot, &mut lines),
|
||||
OperatorScreen::Roaming => {
|
||||
for v in &self.snapshot.schedules {
|
||||
lines.push(format!(
|
||||
"{} {} enabled={} next={:?}",
|
||||
v.job_id, v.kind, v.enabled, v.next_run_unix_millis
|
||||
));
|
||||
}
|
||||
}
|
||||
OperatorScreen::Approvals => {
|
||||
for v in &self.snapshot.approvals {
|
||||
lines.push(format!(
|
||||
"#{} tool={} principal={} expires={}",
|
||||
v.approval_id, v.tool, v.principal, v.expires_unix_seconds
|
||||
));
|
||||
}
|
||||
}
|
||||
OperatorScreen::Timeline => {
|
||||
lines.push(format!("filter={:?}", self.filter));
|
||||
for v in self
|
||||
.snapshot
|
||||
.timeline
|
||||
.iter()
|
||||
.filter(|v| self.filter.matches(v))
|
||||
{
|
||||
lines.push(format!(
|
||||
"{} {:?} {} {} result={:?}",
|
||||
v.unix_millis, v.severity, v.component, v.family, v.result_code
|
||||
));
|
||||
}
|
||||
}
|
||||
OperatorScreen::Health => render_health(&self.snapshot, &mut lines),
|
||||
OperatorScreen::Errors => {
|
||||
for v in &self.errors {
|
||||
lines.push(v.clone());
|
||||
}
|
||||
for v in self
|
||||
.snapshot
|
||||
.timeline
|
||||
.iter()
|
||||
.filter(|v| v.severity == EventSeverity::Error)
|
||||
{
|
||||
lines.push(format!("{} {} {}", v.unix_millis, v.component, v.family));
|
||||
}
|
||||
}
|
||||
OperatorScreen::Diagnostics => {
|
||||
lines.push("redacted diagnostic envelopes; content is never retained".into());
|
||||
for v in self
|
||||
.snapshot
|
||||
.timeline
|
||||
.iter()
|
||||
.filter(|v| v.family == "diagnostic_envelope")
|
||||
{
|
||||
lines.push(format!(
|
||||
"{} {} flags={}",
|
||||
v.unix_millis,
|
||||
v.component,
|
||||
v.redaction_flags.join(",")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let body_height = height.saturating_sub(1);
|
||||
lines
|
||||
.into_iter()
|
||||
.skip(self.scroll.min(body_height))
|
||||
.take(body_height)
|
||||
.map(|line| truncate_width(&line, width))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn page() -> PageRequest {
|
||||
PageRequest {
|
||||
cursor: None,
|
||||
limit: PAGE_LIMIT,
|
||||
}
|
||||
}
|
||||
fn render_overview(s: &OperatorSnapshot, out: &mut Vec<String>) {
|
||||
if let Some(v) = &s.health {
|
||||
out.push(format!(
|
||||
"service={} ready={} uptime={}s",
|
||||
v.service_state, v.ready, v.uptime_seconds
|
||||
));
|
||||
}
|
||||
if let Some(v) = &s.runtime {
|
||||
out.push(format!(
|
||||
"grid={} generation={} connected={} ready={}",
|
||||
v.grid_state, v.generation, v.transport_connected, v.agent_ready
|
||||
));
|
||||
out.push(format!(
|
||||
"region={} pose={:?} behavior={}",
|
||||
v.region_name.as_deref().unwrap_or("unknown"),
|
||||
v.position,
|
||||
v.behavior_mode
|
||||
));
|
||||
}
|
||||
}
|
||||
fn render_queues(s: &OperatorSnapshot, out: &mut Vec<String>) {
|
||||
if let Some(v) = &s.runtime {
|
||||
out.push(format!(
|
||||
"control queue {}/{}",
|
||||
v.control_queue_used, v.control_queue_capacity
|
||||
));
|
||||
out.push(format!(
|
||||
"tool calls={} movement-mm={}",
|
||||
v.budget_tool_calls_used, v.budget_movement_millimeters_used
|
||||
));
|
||||
}
|
||||
if let Some(v) = &s.metrics {
|
||||
out.push(format!(
|
||||
"work queue {}/{} tasks={} rate {}/{}",
|
||||
v.queue_depth,
|
||||
v.queue_capacity,
|
||||
v.active_tasks,
|
||||
v.rate_limit_used,
|
||||
v.rate_limit_capacity
|
||||
));
|
||||
}
|
||||
}
|
||||
fn render_health(s: &OperatorSnapshot, out: &mut Vec<String>) {
|
||||
render_overview(s, out);
|
||||
if let Some(v) = &s.metrics {
|
||||
out.push(format!(
|
||||
"sessions={} reconnects={} events={}",
|
||||
v.active_sessions, v.reconnects, v.recorded_events
|
||||
));
|
||||
out.push(format!(
|
||||
"dropped ring={} subscriber={} journal={}",
|
||||
v.dropped_ring_events, v.dropped_subscriber_events, v.dropped_journal_events
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_width(value: &str, width: usize) -> String {
|
||||
if UnicodeWidthStr::width(value) <= width {
|
||||
return value.to_owned();
|
||||
}
|
||||
let mut result = String::new();
|
||||
let mut used = 0;
|
||||
for ch in value.chars() {
|
||||
let next = ch.width().unwrap_or(0);
|
||||
if used + next + 1 > width {
|
||||
break;
|
||||
}
|
||||
result.push(ch);
|
||||
used += next;
|
||||
}
|
||||
result.push('…');
|
||||
result
|
||||
}
|
||||
|
||||
struct TerminalGuard;
|
||||
impl TerminalGuard {
|
||||
fn enter() -> io::Result<Self> {
|
||||
terminal::enable_raw_mode()?;
|
||||
execute!(io::stdout(), terminal::EnterAlternateScreen, cursor::Hide)?;
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = execute!(io::stdout(), cursor::Show, terminal::LeaveAlternateScreen);
|
||||
let _ = terminal::disable_raw_mode();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs an event-driven keyboard UI. Terminal state is restored on normal
|
||||
/// return, errors, Ctrl-C, and unwinding because restoration is guard-owned.
|
||||
pub async fn run_terminal(client: Arc<dyn TuiTransport>, color: bool) -> Result<(), TuiError> {
|
||||
let _guard = TerminalGuard::enter().map_err(|e| TuiError(e.to_string()))?;
|
||||
let (input_tx, mut input_rx) = tokio::sync::mpsc::channel(64);
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(value) = event::read() {
|
||||
if input_tx.blocking_send(value).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut app = OperatorTui::default();
|
||||
app.refresh(client.as_ref()).await?;
|
||||
let mut refresh = tokio::time::interval(Duration::from_secs(1));
|
||||
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
refresh.tick().await;
|
||||
loop {
|
||||
let (width, height) = terminal::size().unwrap_or((80, 24));
|
||||
let text = app.render(TuiRenderOptions {
|
||||
width,
|
||||
height,
|
||||
color,
|
||||
});
|
||||
let mut stdout = io::stdout();
|
||||
queue!(
|
||||
stdout,
|
||||
cursor::MoveTo(0, 0),
|
||||
terminal::Clear(terminal::ClearType::All),
|
||||
style::Print(text)
|
||||
)
|
||||
.map_err(|e| TuiError(e.to_string()))?;
|
||||
stdout.flush().map_err(|e| TuiError(e.to_string()))?;
|
||||
let value = tokio::select! {
|
||||
value = input_rx.recv() => {
|
||||
let Some(value) = value else { return Ok(()); };
|
||||
value
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
// One coalesced snapshot replaces any number of service changes;
|
||||
// skipped ticks prevent a slow service from building refresh work.
|
||||
if let Err(error) = app.refresh(client.as_ref()).await {
|
||||
app.record_error(error.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let input = match value {
|
||||
event::Event::Resize(_, _) => TuiInput::Refresh,
|
||||
event::Event::Key(key) if key.kind == event::KeyEventKind::Press => match key.code {
|
||||
event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen,
|
||||
event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen,
|
||||
event::KeyCode::Up => TuiInput::ScrollUp,
|
||||
event::KeyCode::Down => TuiInput::ScrollDown,
|
||||
event::KeyCode::Char('r') => TuiInput::Refresh,
|
||||
event::KeyCode::Char(
|
||||
key @ ('p' | 'u' | 'f' | 'a' | 'd' | 'x' | 'e' | 't' | 's'),
|
||||
) => {
|
||||
let Some(command) = app.command_shortcut(key) else {
|
||||
continue;
|
||||
};
|
||||
TuiInput::Command(command)
|
||||
}
|
||||
event::KeyCode::Char('y') => TuiInput::Confirm,
|
||||
event::KeyCode::Char('n') | event::KeyCode::Esc => TuiInput::Reject,
|
||||
event::KeyCode::Char('q') => TuiInput::Quit,
|
||||
event::KeyCode::Char('c')
|
||||
if key.modifiers.contains(event::KeyModifiers::CONTROL) =>
|
||||
{
|
||||
TuiInput::Quit
|
||||
}
|
||||
_ => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
match app.reduce(input) {
|
||||
TuiAction::None => {}
|
||||
TuiAction::Exit => return Ok(()),
|
||||
TuiAction::Refresh => app.refresh(client.as_ref()).await?,
|
||||
TuiAction::Execute(command) => app.execute(client.as_ref(), command).await?,
|
||||
}
|
||||
}
|
||||
}
|
||||
214
crates/metacrate-grid-agent/src/tui_tests.rs
Normal file
214
crates/metacrate-grid-agent/src/tui_tests.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use crate::CONTROL_PROTOCOL_VERSION;
|
||||
use crate::control_plane::{
|
||||
ControlPayload, ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, HealthView,
|
||||
};
|
||||
use crate::tui::{
|
||||
CommandConfirmation, OperatorCommand, OperatorScreen, OperatorTui, TuiAction, TuiError,
|
||||
TuiFuture, TuiInput, TuiRenderOptions, TuiTransport,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct FakeTransport(Mutex<Vec<ControlRequest>>);
|
||||
|
||||
impl FakeTransport {
|
||||
fn new() -> Self {
|
||||
Self(Mutex::new(Vec::new()))
|
||||
}
|
||||
}
|
||||
|
||||
impl TuiTransport for FakeTransport {
|
||||
fn request(&self, request: ControlRequestEnvelope) -> TuiFuture<'_> {
|
||||
self.0
|
||||
.lock()
|
||||
.expect("requests")
|
||||
.push(request.request.clone());
|
||||
Box::pin(async move {
|
||||
let result = match request.request {
|
||||
ControlRequest::Health => ControlPayload::Health(HealthView {
|
||||
service_state: "running".into(),
|
||||
ready: true,
|
||||
uptime_seconds: 7,
|
||||
protocol_version: CONTROL_PROTOCOL_VERSION,
|
||||
}),
|
||||
ControlRequest::Runtime => ControlPayload::Runtime(crate::RuntimeView {
|
||||
grid_state: "ready".into(),
|
||||
generation: 2,
|
||||
transport_connected: true,
|
||||
agent_ready: true,
|
||||
region_id: Some("region".into()),
|
||||
region_name: Some("Café 世界".into()),
|
||||
position: Some([1.0, 2.0, 3.0]),
|
||||
behavior_mode: "active".into(),
|
||||
control_queue_used: 1,
|
||||
control_queue_capacity: 8,
|
||||
budget_tool_calls_used: 2,
|
||||
budget_movement_millimeters_used: 3,
|
||||
}),
|
||||
ControlRequest::ListSessions { .. } => ControlPayload::Sessions(crate::Page {
|
||||
items: Vec::new(),
|
||||
next_cursor: None,
|
||||
}),
|
||||
ControlRequest::ListScheduledJobs { .. } => {
|
||||
ControlPayload::ScheduledJobs(crate::Page {
|
||||
items: Vec::new(),
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
ControlRequest::ListPendingApprovals { .. } => {
|
||||
ControlPayload::PendingApprovals(crate::Page {
|
||||
items: Vec::new(),
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
ControlRequest::ListAuditEvents { .. } => {
|
||||
ControlPayload::AuditEvents(crate::Page {
|
||||
items: Vec::new(),
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
ControlRequest::ListObservabilityEvents { .. } => {
|
||||
ControlPayload::ObservabilityEvents(crate::Page {
|
||||
items: Vec::new(),
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
ControlRequest::Metrics => ControlPayload::Metrics(empty_metrics()),
|
||||
_ => ControlPayload::Completed,
|
||||
};
|
||||
Ok(ControlResponseEnvelope {
|
||||
version: CONTROL_PROTOCOL_VERSION,
|
||||
request_id: request.request_id,
|
||||
result: Ok(result),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_metrics() -> crate::MetricsSnapshot {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"ready":true,"reconnects":0,"active_sessions":0,"active_tasks":0,
|
||||
"queue_depth":0,"queue_capacity":8,
|
||||
"inference_latency":{"samples":0,"total_millis":0,"maximum_millis":0},
|
||||
"tool_latency":{"samples":0,"total_millis":0,"maximum_millis":0},
|
||||
"inference_outcomes":{"allowed":0,"denied":0,"approval_required":0,"completed":0,"failed":0,"cancelled":0},
|
||||
"tool_outcomes":{"allowed":0,"denied":0,"approval_required":0,"completed":0,"failed":0,"cancelled":0},
|
||||
"policy_outcomes":{"allowed":0,"denied":0,"approval_required":0,"completed":0,"failed":0,"cancelled":0},
|
||||
"rate_limit_used":0,"rate_limit_capacity":10,"recorded_events":0,
|
||||
"dropped_ring_events":0,"dropped_subscriber_events":0,"dropped_journal_events":0,
|
||||
"diagnostic_entries":0
|
||||
})).expect("metrics fixture")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_refresh_is_transport_neutral_and_bounded() {
|
||||
let transport = FakeTransport::new();
|
||||
let mut app = OperatorTui::default();
|
||||
app.refresh(&transport).await.expect("refresh");
|
||||
assert_eq!(
|
||||
app.snapshot.health.as_ref().expect("health").service_state,
|
||||
"running"
|
||||
);
|
||||
assert_eq!(transport.0.lock().expect("requests").len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_screen_renders_without_a_terminal_at_small_unicode_and_mono_sizes() {
|
||||
let mut app = OperatorTui::default();
|
||||
for screen in [
|
||||
OperatorScreen::Overview,
|
||||
OperatorScreen::Sessions,
|
||||
OperatorScreen::QueuesAndBudgets,
|
||||
OperatorScreen::Roaming,
|
||||
OperatorScreen::Approvals,
|
||||
OperatorScreen::Timeline,
|
||||
OperatorScreen::Health,
|
||||
OperatorScreen::Errors,
|
||||
OperatorScreen::Diagnostics,
|
||||
] {
|
||||
while app.screen != screen {
|
||||
let _ = app.reduce(TuiInput::NextScreen);
|
||||
}
|
||||
let output = app.render(TuiRenderOptions {
|
||||
width: 20,
|
||||
height: 5,
|
||||
color: false,
|
||||
});
|
||||
assert!(!output.is_empty());
|
||||
assert!(output.lines().count() <= 4);
|
||||
assert!(
|
||||
output
|
||||
.lines()
|
||||
.all(|line| unicode_width::UnicodeWidthStr::width(line) <= 20)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_wraps_and_every_management_command_has_risk_confirmation() {
|
||||
let mut app = OperatorTui::default();
|
||||
assert_eq!(app.reduce(TuiInput::PreviousScreen), TuiAction::None);
|
||||
assert_eq!(app.screen, OperatorScreen::Diagnostics);
|
||||
let commands = [
|
||||
OperatorCommand::Pause,
|
||||
OperatorCommand::Resume,
|
||||
OperatorCommand::CancelAction("action-1".into()),
|
||||
OperatorCommand::DecideApproval {
|
||||
id: 1,
|
||||
approve: true,
|
||||
},
|
||||
OperatorCommand::Reconnect,
|
||||
OperatorCommand::ExpireSession {
|
||||
avatar_id: "00000000-0000-0000-0000-000000000001".into(),
|
||||
direct_im: true,
|
||||
},
|
||||
OperatorCommand::ToggleSchedule {
|
||||
job_id: "default-roaming".into(),
|
||||
enabled: false,
|
||||
},
|
||||
OperatorCommand::Shutdown,
|
||||
];
|
||||
for command in commands {
|
||||
let confirmation = command.confirmation();
|
||||
let action = app.reduce(TuiInput::Command(command.clone()));
|
||||
if confirmation == CommandConfirmation::None {
|
||||
assert_eq!(action, TuiAction::Execute(command));
|
||||
} else {
|
||||
assert_eq!(action, TuiAction::None);
|
||||
assert_eq!(app.reduce(TuiInput::Confirm), TuiAction::Execute(command));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_global_command_has_a_keyboard_path() {
|
||||
let app = OperatorTui::default();
|
||||
assert_eq!(app.command_shortcut('p'), Some(OperatorCommand::Pause));
|
||||
assert_eq!(app.command_shortcut('u'), Some(OperatorCommand::Resume));
|
||||
assert_eq!(app.command_shortcut('f'), Some(OperatorCommand::Reconnect));
|
||||
assert_eq!(app.command_shortcut('s'), Some(OperatorCommand::Shutdown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_errors_are_safe_display_text() {
|
||||
struct Broken;
|
||||
impl TuiTransport for Broken {
|
||||
fn request(&self, _: ControlRequestEnvelope) -> TuiFuture<'_> {
|
||||
Box::pin(async { Err(TuiError("transport closed".into())) })
|
||||
}
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime");
|
||||
let mut app = OperatorTui::default();
|
||||
let error = runtime.block_on(app.refresh(&Broken)).expect_err("failure");
|
||||
assert_eq!(error.to_string(), "transport closed");
|
||||
assert!(
|
||||
!app.render(TuiRenderOptions {
|
||||
width: 80,
|
||||
height: 24,
|
||||
color: false
|
||||
})
|
||||
.contains("token")
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const ALLOWED_DEPENDENCIES: [&str; 10] = [
|
||||
const ALLOWED_DEPENDENCIES: [&str; 12] = [
|
||||
"crossterm",
|
||||
"libremetaverse",
|
||||
"libremetaverse-types",
|
||||
"reqwest",
|
||||
@@ -13,6 +14,7 @@ const ALLOWED_DEPENDENCIES: [&str; 10] = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"url",
|
||||
"unicode-width",
|
||||
];
|
||||
|
||||
#[test]
|
||||
@@ -48,7 +50,7 @@ fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
|
||||
let mut files = Vec::with_capacity(20);
|
||||
collect_rust_files(&source, &mut files);
|
||||
assert!(
|
||||
files.len() <= 26,
|
||||
files.len() <= 28,
|
||||
"source-file count needs a reviewed bound update"
|
||||
);
|
||||
for path in files {
|
||||
|
||||
51
docs/grid-agent-tui.md
Normal file
51
docs/grid-agent-tui.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Grid-agent operator TUI
|
||||
|
||||
The operator TUI is a pure-Rust, cross-platform view of the versioned control
|
||||
plane. Embedded and split modes use the same reducer, snapshots, commands, and
|
||||
authenticated control requests; the UI never reads private runtime state.
|
||||
|
||||
Run an embedded UI with a live-grid integrated configuration:
|
||||
|
||||
```sh
|
||||
cargo run --locked -p metacrate-grid-agent --features live-grid -- \
|
||||
--config config/grid-agent.json --tui
|
||||
```
|
||||
|
||||
Run a separate UI against a loopback split service:
|
||||
|
||||
```sh
|
||||
cargo run --locked -p metacrate-grid-agent -- \
|
||||
--config config/grid-agent.json --tui-client
|
||||
```
|
||||
|
||||
The split client reads its operator capability through the normal secret-file
|
||||
or environment configuration. It never displays or copies that value. Remote
|
||||
TLS clients must embed `TuiTransport` with their explicitly configured trust
|
||||
roots; the command-line client intentionally accepts loopback TCP only.
|
||||
|
||||
Use Tab/arrow keys to change panels and scroll. `r` refreshes; `p`/`u` pause or
|
||||
resume; `f` reconnects the grid; `a`/`d` approve or deny the first pending
|
||||
approval; `x` cancels the newest visible action; `e` expires the first visible
|
||||
session; `t` toggles the first roaming schedule; `s` requests shutdown; and
|
||||
`q` or Ctrl-C exits. Mutations other than pause/resume require confirmation,
|
||||
with shutdown marked high risk. Exiting or crashing a split TUI does not stop
|
||||
the service.
|
||||
|
||||
Rendering is terminal-independent, Unicode display-width aware, responsive to
|
||||
resize events, and monochrome when `NO_COLOR` is set or `TERM=dumb`. Input uses
|
||||
a bounded queue. Snapshot ticks coalesce while a request is slow, and timeline
|
||||
storage is bounded. The terminal guard restores raw mode, cursor visibility,
|
||||
and the alternate screen on success, error, Ctrl-C, or panic unwinding.
|
||||
|
||||
Diagnostic panels show only explicitly captured redacted envelopes. Prompt
|
||||
and response content, API keys, grid passwords, operator tokens, capability
|
||||
URLs, and model reasoning have no TUI representation.
|
||||
|
||||
Focused verification:
|
||||
|
||||
```sh
|
||||
cargo test --locked -p metacrate-grid-agent --lib tui_tests
|
||||
cargo test --locked -p metacrate-grid-agent --test dependency_policy
|
||||
cargo clippy --locked -p metacrate-grid-agent --all-targets --features live-grid -- -D warnings
|
||||
RUSTDOCFLAGS="-D warnings" cargo doc --locked -p metacrate-grid-agent --no-deps
|
||||
```
|
||||
Reference in New Issue
Block a user