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

@@ -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"] }

View File

@@ -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).

View File

@@ -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,

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;

View 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?,
}
}
}

View 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")
);
}

View File

@@ -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 {