From c6a70081f6c19d9a62883f23eee72f739a38db43 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 10:45:13 -0400 Subject: [PATCH 01/35] feat: add standalone capsem tui shell --- CHANGELOG.md | 5 + Cargo.toml | 3 + crates/capsem-tui/Cargo.toml | 24 +++ crates/capsem-tui/src/fixture.rs | 77 +++++++ crates/capsem-tui/src/lib.rs | 7 + crates/capsem-tui/src/main.rs | 82 +++++++ crates/capsem-tui/src/model.rs | 109 ++++++++++ crates/capsem-tui/src/provider.rs | 7 + crates/capsem-tui/src/tests.rs | 30 +++ crates/capsem-tui/src/ui.rs | 202 ++++++++++++++++++ sprints/tui-control/MASTER.md | 57 +++++ .../T00-crate-setup-basic-screen.md | 47 ++++ sprints/tui-control/tracker.md | 32 +++ 13 files changed, 682 insertions(+) create mode 100644 crates/capsem-tui/Cargo.toml create mode 100644 crates/capsem-tui/src/fixture.rs create mode 100644 crates/capsem-tui/src/lib.rs create mode 100644 crates/capsem-tui/src/main.rs create mode 100644 crates/capsem-tui/src/model.rs create mode 100644 crates/capsem-tui/src/provider.rs create mode 100644 crates/capsem-tui/src/tests.rs create mode 100644 crates/capsem-tui/src/ui.rs create mode 100644 sprints/tui-control/MASTER.md create mode 100644 sprints/tui-control/T00-crate-setup-basic-screen.md create mode 100644 sprints/tui-control/tracker.md diff --git a/CHANGELOG.md b/CHANGELOG.md index afe82006..5b814086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added the initial `capsem-tui` crate with a fixture-backed standalone + terminal control screen, global service light-bar state, per-session desktop + indicators, and deterministic snapshot rendering for early UI proof. + ### Changed - Split Google into its own `sprints/google/` meta sprint covering Gmail, Drive, gcloud, Firebase, Firebase Realtime DB remote comms, Jet Ski, Gemini, diff --git a/Cargo.toml b/Cargo.toml index 3604ed54..f5a9ed9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/capsem-process", "crates/capsem-service", "crates/capsem", + "crates/capsem-tui", "crates/capsem-mcp", "crates/capsem-mcp-aggregator", "crates/capsem-mcp-builtin", @@ -102,6 +103,8 @@ base64 = "0.22" bytes = "1" regex = "1" clap = { version = "4", features = ["derive"] } +ratatui = "0.30.0" +crossterm = "0.29.0" tokio-unix-ipc = "0.4" rmcp = { version = "1.3", features = ["client", "server"] } # Low-level DNS protocol (wire-format codec). Used host-side by the diff --git a/crates/capsem-tui/Cargo.toml b/crates/capsem-tui/Cargo.toml new file mode 100644 index 00000000..fd1a5ccd --- /dev/null +++ b/crates/capsem-tui/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "capsem-tui" +version.workspace = true +edition = "2021" +rust-version.workspace = true +license.workspace = true +description.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true + +[[bin]] +name = "capsem-tui" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +crossterm.workspace = true +ratatui.workspace = true + +[lints] +workspace = true + diff --git a/crates/capsem-tui/src/fixture.rs b/crates/capsem-tui/src/fixture.rs new file mode 100644 index 00000000..cf6210f1 --- /dev/null +++ b/crates/capsem-tui/src/fixture.rs @@ -0,0 +1,77 @@ +use std::time::Duration; + +use anyhow::Result; + +use crate::model::{ + AppState, Attention, ServiceState, ServiceStatus, SessionLifecycle, SessionStats, + SessionSummary, +}; +use crate::provider::StateProvider; + +#[derive(Default)] +pub struct FixtureProvider; + +impl StateProvider for FixtureProvider { + fn load(&self) -> Result { + Ok(fixture_state()) + } +} + +pub fn fixture_state() -> AppState { + AppState { + service: ServiceState { + status: ServiceStatus::Online, + latency: Duration::from_millis(18), + last_event_age: Duration::from_millis(240), + reconnect_attempt: None, + }, + active_session_id: "profile-v2".to_string(), + sessions: vec![ + SessionSummary { + id: "profile-v2".to_string(), + title: "Profile V2".to_string(), + repo_path: Some("github.com/google/capsem".to_string()), + profile: "corp-default".to_string(), + branch: Some("codex/tui-control".to_string()), + lifecycle: SessionLifecycle::Working, + attention: Vec::new(), + stats: SessionStats { + jobs: 2, + events: 148, + cpu_percent: 18, + memory_mb: 768, + }, + }, + SessionSummary { + id: "linux-os".to_string(), + title: "Linux OS".to_string(), + repo_path: Some("github.com/google/capsem-linux".to_string()), + profile: "linux-builder".to_string(), + branch: Some("resume-fix".to_string()), + lifecycle: SessionLifecycle::WaitingForInput, + attention: vec![Attention::Bell], + stats: SessionStats { + jobs: 1, + events: 62, + cpu_percent: 4, + memory_mb: 512, + }, + }, + SessionSummary { + id: "security".to_string(), + title: "Security".to_string(), + repo_path: None, + profile: "high-risk".to_string(), + branch: None, + lifecycle: SessionLifecycle::Suspended, + attention: vec![Attention::ApprovalRequired, Attention::StaleData], + stats: SessionStats { + jobs: 0, + events: 311, + cpu_percent: 0, + memory_mb: 256, + }, + }, + ], + } +} diff --git a/crates/capsem-tui/src/lib.rs b/crates/capsem-tui/src/lib.rs new file mode 100644 index 00000000..cca7c601 --- /dev/null +++ b/crates/capsem-tui/src/lib.rs @@ -0,0 +1,7 @@ +pub mod fixture; +pub mod model; +pub mod provider; +pub mod ui; + +#[cfg(test)] +mod tests; diff --git a/crates/capsem-tui/src/main.rs b/crates/capsem-tui/src/main.rs new file mode 100644 index 00000000..646395fd --- /dev/null +++ b/crates/capsem-tui/src/main.rs @@ -0,0 +1,82 @@ +use std::io; +use std::time::Duration; + +use anyhow::Result; +use capsem_tui::fixture::FixtureProvider; +use capsem_tui::provider::StateProvider; +use capsem_tui::ui::{render, render_snapshot}; +use clap::Parser; +use crossterm::event::{self, Event, KeyCode}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +#[derive(Parser)] +#[command( + author, + version, + about = "Standalone Capsem terminal control UI prototype" +)] +struct Cli { + /// Print a deterministic text rendering instead of opening the terminal UI. + #[arg(long)] + snapshot: bool, + + /// Snapshot width. + #[arg(long, default_value_t = 100)] + width: u16, + + /// Snapshot height. + #[arg(long, default_value_t = 24)] + height: u16, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let provider = FixtureProvider; + let state = provider.load()?; + + if cli.snapshot { + println!("{}", render_snapshot(&state, cli.width, cli.height)?); + return Ok(()); + } + + run_interactive(&state) +} + +fn run_interactive(state: &capsem_tui::model::AppState) -> Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = run_loop(&mut terminal, state); + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + result +} + +fn run_loop( + terminal: &mut Terminal>, + state: &capsem_tui::model::AppState, +) -> Result<()> { + loop { + terminal.draw(|frame| render(frame, state))?; + if event::poll(Duration::from_millis(250))? { + match event::read()? { + Event::Key(key) if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) => { + break; + } + _ => {} + } + } + } + Ok(()) +} diff --git a/crates/capsem-tui/src/model.rs b/crates/capsem-tui/src/model.rs new file mode 100644 index 00000000..63609a47 --- /dev/null +++ b/crates/capsem-tui/src/model.rs @@ -0,0 +1,109 @@ +use std::time::Duration; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppState { + pub service: ServiceState, + pub active_session_id: String, + pub sessions: Vec, +} + +impl AppState { + pub fn active_session(&self) -> Option<&SessionSummary> { + self.sessions + .iter() + .find(|session| session.id == self.active_session_id) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServiceState { + pub status: ServiceStatus, + pub latency: Duration, + pub last_event_age: Duration, + pub reconnect_attempt: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceStatus { + Online, + Reconnecting, + Stale, + Offline, + Degraded, + Failed, +} + +impl ServiceStatus { + pub const fn label(self) -> &'static str { + match self { + Self::Online => "online", + Self::Reconnecting => "reconnecting", + Self::Stale => "stale", + Self::Offline => "offline", + Self::Degraded => "degraded", + Self::Failed => "failed", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionSummary { + pub id: String, + pub title: String, + pub repo_path: Option, + pub profile: String, + pub branch: Option, + pub lifecycle: SessionLifecycle, + pub attention: Vec, + pub stats: SessionStats, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionLifecycle { + Idle, + Suspended, + Working, + WaitingForInput, + Failed, +} + +impl SessionLifecycle { + pub const fn label(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Suspended => "suspended", + Self::Working => "working", + Self::WaitingForInput => "waiting", + Self::Failed => "failed", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Attention { + Bell, + ApprovalRequired, + PolicyDeny, + CredentialIssue, + StaleData, +} + +impl Attention { + pub const fn marker(self) -> &'static str { + match self { + Self::Bell => "bell", + Self::ApprovalRequired => "approval", + Self::PolicyDeny => "policy", + Self::CredentialIssue => "creds", + Self::StaleData => "stale", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionStats { + pub jobs: u16, + pub events: u32, + pub cpu_percent: u8, + pub memory_mb: u32, +} diff --git a/crates/capsem-tui/src/provider.rs b/crates/capsem-tui/src/provider.rs new file mode 100644 index 00000000..fea6cde3 --- /dev/null +++ b/crates/capsem-tui/src/provider.rs @@ -0,0 +1,7 @@ +use anyhow::Result; + +use crate::model::AppState; + +pub trait StateProvider { + fn load(&self) -> Result; +} diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs new file mode 100644 index 00000000..d018674e --- /dev/null +++ b/crates/capsem-tui/src/tests.rs @@ -0,0 +1,30 @@ +use crate::fixture::fixture_state; +use crate::model::{Attention, ServiceStatus, SessionLifecycle}; +use crate::ui::render_snapshot; + +#[test] +fn fixture_models_global_service_state_and_session_indicators() { + let state = fixture_state(); + + assert_eq!(state.service.status, ServiceStatus::Online); + assert_eq!( + state.sessions[0].lifecycle, + SessionLifecycle::Working, + "active desktop should be working in the fixture" + ); + assert!( + state.sessions[1].attention.contains(&Attention::Bell), + "fixture needs one terminal-bell attention indicator" + ); +} + +#[test] +fn snapshot_contains_light_bar_tabs_and_active_desktop() { + let snapshot = render_snapshot(&fixture_state(), 100, 24).expect("render snapshot"); + + assert!(snapshot.contains("svc=online latency=18ms")); + assert!(snapshot.contains("Profile V2")); + assert!(snapshot.contains("Linux OS !")); + assert!(snapshot.contains("repo: github.com/google/capsem")); + assert!(snapshot.contains("< > switch desktop")); +} diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs new file mode 100644 index 00000000..787b70dd --- /dev/null +++ b/crates/capsem-tui/src/ui.rs @@ -0,0 +1,202 @@ +use anyhow::Result; +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Tabs, Wrap}; +use ratatui::{Frame, Terminal}; + +use crate::model::{AppState, Attention, ServiceStatus, SessionLifecycle, SessionSummary}; + +pub fn render(frame: &mut Frame<'_>, state: &AppState) { + let root = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(8), + Constraint::Length(1), + ]) + .split(root); + + render_light_bar(frame, state, chunks[0]); + render_tabs(frame, state, chunks[1]); + render_active_desktop(frame, state, chunks[2]); + render_footer(frame, chunks[3]); +} + +pub fn render_snapshot(state: &AppState, width: u16, height: u16) -> Result { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|frame| render(frame, state))?; + Ok(buffer_to_string(terminal.backend().buffer())) +} + +fn render_light_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { + let service = &state.service; + let active = state.active_session(); + let service_style = match service.status { + ServiceStatus::Online => Style::default().fg(Color::Green), + ServiceStatus::Reconnecting | ServiceStatus::Stale | ServiceStatus::Degraded => { + Style::default().fg(Color::Yellow) + } + ServiceStatus::Offline | ServiceStatus::Failed => Style::default().fg(Color::Red), + }; + let mut spans = vec![ + Span::styled(" Capsem ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw("svc="), + Span::styled(service.status.label(), service_style), + Span::raw(format!( + " latency={}ms event-age={}ms", + service.latency.as_millis(), + service.last_event_age.as_millis() + )), + ]; + if let Some(attempt) = service.reconnect_attempt { + spans.push(Span::raw(format!(" reconnect=#{attempt}"))); + } + if let Some(session) = active { + spans.push(Span::raw(" | ")); + spans.push(Span::raw(session_info(session))); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn render_tabs(frame: &mut Frame<'_>, state: &AppState, area: Rect) { + let titles = state + .sessions + .iter() + .map(tab_title) + .collect::>>(); + let active_index = state + .sessions + .iter() + .position(|session| session.id == state.active_session_id) + .unwrap_or_default(); + let tabs = Tabs::new(titles) + .select(active_index) + .block(Block::default().borders(Borders::ALL).title("desktops")) + .style(Style::default().fg(Color::Gray)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + frame.render_widget(tabs, area); +} + +fn render_active_desktop(frame: &mut Frame<'_>, state: &AppState, area: Rect) { + let Some(session) = state.active_session() else { + frame.render_widget( + Paragraph::new("No active session. Press n to create one.") + .block(Block::default().borders(Borders::ALL).title("desktop")), + area, + ); + return; + }; + + let repo = session.repo_path.as_deref().unwrap_or("no repo"); + let branch = session.branch.as_deref().unwrap_or("no branch"); + let attention = attention_text(session); + let text = vec![ + Line::from(vec![ + Span::styled(&session.title, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!(" {}", session.lifecycle.label())), + ]), + Line::from(format!("profile: {}", session.profile)), + Line::from(format!("repo: {repo}")), + Line::from(format!("branch: {branch}")), + Line::from(format!( + "stats: jobs={} events={} cpu={} memory={}MiB", + session.stats.jobs, + session.stats.events, + session.stats.cpu_percent, + session.stats.memory_mb + )), + Line::from(format!("attention: {attention}")), + Line::from(""), + Line::from("Fixture desktop surface. Real terminal attach and HTTP state arrive in later sub-sprints."), + ]; + let paragraph = Paragraph::new(text) + .block( + Block::default() + .borders(Borders::ALL) + .title("active desktop"), + ) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, area); +} + +fn render_footer(frame: &mut Frame<'_>, area: Rect) { + frame.render_widget( + Paragraph::new( + " < > switch desktop ^ sessions / search : command ? help q quit ", + ), + area, + ); +} + +fn tab_title(session: &SessionSummary) -> Line<'static> { + let attention = if session.attention.is_empty() { + "" + } else { + " !" + }; + let marker = lifecycle_marker(session.lifecycle); + Line::from(format!(" {marker} {}{attention} ", session.title)) +} + +fn lifecycle_marker(lifecycle: SessionLifecycle) -> &'static str { + match lifecycle { + SessionLifecycle::Idle => "idle", + SessionLifecycle::Suspended => "susp", + SessionLifecycle::Working => "work", + SessionLifecycle::WaitingForInput => "wait", + SessionLifecycle::Failed => "fail", + } +} + +fn session_info(session: &SessionSummary) -> String { + let repo = session.repo_path.as_deref().unwrap_or("no repo"); + let branch = session.branch.as_deref().unwrap_or("no branch"); + format!( + "session={} profile={} repo={} branch={}", + session.title, session.profile, repo, branch + ) +} + +fn attention_text(session: &SessionSummary) -> String { + if session.attention.is_empty() { + return "none".to_string(); + } + session + .attention + .iter() + .map(|attention| match attention { + Attention::Bell => "bell", + Attention::ApprovalRequired => "approval", + Attention::PolicyDeny => "policy", + Attention::CredentialIssue => "creds", + Attention::StaleData => "stale", + }) + .collect::>() + .join(", ") +} + +fn buffer_to_string(buffer: &Buffer) -> String { + let width = buffer.area.width as usize; + buffer + .content() + .chunks(width) + .map(|row| { + row.iter() + .map(|cell| cell.symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md new file mode 100644 index 00000000..8d26097c --- /dev/null +++ b/sprints/tui-control/MASTER.md @@ -0,0 +1,57 @@ +# Capsem TUI Control Meta Sprint + +Status: In Progress + +## Goal + +Build a Rust-native terminal control plane for Capsem that feels like switching +between lightweight VM/agent desktops, not operating a dashboard. The TUI is a +thin client over typed state and actions exposed by Capsem service/gateway APIs. + +## Product Contract + +- Global service state belongs in the light/status bar. +- Per-session/tab state shows lifecycle and attention only: idle, suspended, + working, waiting for input, approval required, failed, bell, stale. +- The TUI must not infer unavailable state. If a field is missing from the + service HTTP model, it becomes a service/API requirement. +- Basic UI can run standalone with fixture state before real gateway wiring. +- Full stats, session picker, help, and new-session flows are overlays/screens. + +## Sub-Sprints + +| ID | Status | Scope | Proof | +| --- | --- | --- | --- | +| T00 | Done | Crate setup and standalone fixture screen | `cargo test -p capsem-tui`; snapshot command | +| T01 | Not Started | Terminal screenshot/snapshot proof path | buffer snapshots; screenshot/export strategy | +| T02 | Not Started | Multiple desktop tabs and per-session indicators | render tests for active/attention states | +| T03 | Not Started | Keyboard controls and focus/modal ownership | key-sequence tests | +| T04 | Not Started | Help, full statistics, and new-session screens | screen render tests | +| T05 | Not Started | Home/resume screen with profile/session list | fixture render tests | +| T06 | Not Started | Typed HTTP/service model inventory and API gaps | service schema gap doc | +| T07 | Not Started | Wire local gateway/service read-only state | fake + live gateway tests | +| T08 | Not Started | Safe service control actions | confirmation/action tests | +| T09 | Not Started | Remote transport readiness | reconnect/event cursor tests | + +## Current Decision + +Wire a provider boundary early, but do not wire real Capsem HTTP behavior until +the standalone shell, tab model, keyboard model, and overlays are testable. The +first crate uses fixture state through the same interface later used by HTTP. + +## T00 Closeout + +- Added `crates/capsem-tui`. +- Added fixture state with global service health and per-session indicators. +- Added a basic Ratatui screen and deterministic `--snapshot` output. +- Deferred real screenshot export/CAPSEM MCP capture to T01 because the current + exposed Capsem MCP tool surface does not include terminal screenshot capture. + +## Testing Gate + +- Unit/contract: required for state and render logic. +- Functional: standalone demo and text/snapshot render output. +- Adversarial: malformed/missing fields once the HTTP model exists. +- E2E/VM: deferred until service wiring begins. +- Telemetry: deferred until service wiring begins. +- Performance: frame/render timing deferred until interactive loop exists. diff --git a/sprints/tui-control/T00-crate-setup-basic-screen.md b/sprints/tui-control/T00-crate-setup-basic-screen.md new file mode 100644 index 00000000..d81a5e68 --- /dev/null +++ b/sprints/tui-control/T00-crate-setup-basic-screen.md @@ -0,0 +1,47 @@ +# T00: Crate Setup And Basic Standalone Screen + +Status: Done + +## Scope + +Create `crates/capsem-tui` as a Rust binary/library crate with: + +- fixture-backed app state; +- global service/light-bar model; +- per-session tab lifecycle/attention model; +- basic Ratatui screen rendering; +- standalone terminal demo; +- non-interactive render output for tests and agent inspection. + +## Non-Goals + +- No real gateway/service HTTP calls yet. +- No session creation/control yet. +- No Firebase/remote transport yet. +- No real terminal attach yet. + +## Design Notes + +- The UI is a dumb renderer over typed state. +- Missing fields must not be inferred locally. +- The provider trait exists from day one so fixture and future HTTP providers + use the same contract. +- Screenshot inspection through Capsem MCP is not currently available from the + exposed MCP tools. T00 will provide buffer/text render proof first; T01 will + decide whether to add ANSI/SVG/PNG export or a Capsem MCP capture tool. + +## Done + +- Workspace includes `crates/capsem-tui`. +- `cargo test -p capsem-tui` passes. +- `cargo run -p capsem-tui -- --snapshot` prints a stable standalone screen. +- `cargo run -p capsem-tui` opens an interactive fixture demo. + +## Coverage Ledger + +- Unit/contract: `cargo test -p capsem-tui`. +- Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`. +- Adversarial: not applicable until input/API parsing exists. +- E2E/VM: deferred to service wiring. +- Telemetry: deferred to service wiring. +- Performance: deferred to interactive loop polish. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md new file mode 100644 index 00000000..332f80de --- /dev/null +++ b/sprints/tui-control/tracker.md @@ -0,0 +1,32 @@ +# Sprint: TUI Control + +## Active Sub-Sprint: T00 + +- [x] Create meta sprint and T00 plan. +- [x] Add `capsem-tui` workspace crate. +- [x] Define fixture app state and provider boundary. +- [x] Render basic standalone screen. +- [x] Add snapshot/text render proof. +- [x] Add changelog entry. +- [x] Run focused tests. +- [ ] Commit functional milestone. + +## Notes + +- Product correction: service/transport state is global, not per-tab. +- Per-tab indicators are lifecycle and attention state only. +- UI may only render fields exposed by typed state. If service HTTP does not + expose a field, the UI cannot use it. +- Capsem MCP is connected but no screenshot/capture tool is exposed in the + current tool surface. +- T00 snapshot at 100x24 confirms the basic layout and also shows light-bar + clipping pressure for long repo/session metadata. + +## Coverage Ledger + +- Unit/contract: `cargo test -p capsem-tui`. +- Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`. +- Adversarial: deferred until API/input parsing. +- E2E/VM: deferred until service wiring. +- Telemetry: deferred until service wiring. +- Performance: deferred until interactive frame loop work. From 2e79056bcc76860457de05a77b9967e65e2ff564 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 11:49:15 -0400 Subject: [PATCH 02/35] style: simplify capsem tui chrome --- crates/capsem-tui/src/fixture.rs | 15 +- crates/capsem-tui/src/model.rs | 5 +- crates/capsem-tui/src/tests.rs | 16 +- crates/capsem-tui/src/ui.rs | 257 ++++++++++-------- .../T00-crate-setup-basic-screen.md | 2 + sprints/tui-control/tracker.md | 3 + 6 files changed, 175 insertions(+), 123 deletions(-) diff --git a/crates/capsem-tui/src/fixture.rs b/crates/capsem-tui/src/fixture.rs index cf6210f1..080dabb8 100644 --- a/crates/capsem-tui/src/fixture.rs +++ b/crates/capsem-tui/src/fixture.rs @@ -36,10 +36,11 @@ pub fn fixture_state() -> AppState { lifecycle: SessionLifecycle::Working, attention: Vec::new(), stats: SessionStats { + duration: Duration::from_secs(47 * 60), jobs: 2, events: 148, - cpu_percent: 18, - memory_mb: 768, + tokens: 38_420, + cost_micros: 214_000, }, }, SessionSummary { @@ -51,10 +52,11 @@ pub fn fixture_state() -> AppState { lifecycle: SessionLifecycle::WaitingForInput, attention: vec![Attention::Bell], stats: SessionStats { + duration: Duration::from_secs(2 * 60 * 60 + 11 * 60), jobs: 1, events: 62, - cpu_percent: 4, - memory_mb: 512, + tokens: 12_900, + cost_micros: 76_000, }, }, SessionSummary { @@ -66,10 +68,11 @@ pub fn fixture_state() -> AppState { lifecycle: SessionLifecycle::Suspended, attention: vec![Attention::ApprovalRequired, Attention::StaleData], stats: SessionStats { + duration: Duration::from_secs(19 * 60), jobs: 0, events: 311, - cpu_percent: 0, - memory_mb: 256, + tokens: 91_250, + cost_micros: 488_000, }, }, ], diff --git a/crates/capsem-tui/src/model.rs b/crates/capsem-tui/src/model.rs index 63609a47..c92af9a3 100644 --- a/crates/capsem-tui/src/model.rs +++ b/crates/capsem-tui/src/model.rs @@ -102,8 +102,9 @@ impl Attention { #[derive(Clone, Debug, Eq, PartialEq)] pub struct SessionStats { + pub duration: Duration, pub jobs: u16, pub events: u32, - pub cpu_percent: u8, - pub memory_mb: u32, + pub tokens: u64, + pub cost_micros: u64, } diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index d018674e..cb85357a 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -22,9 +22,17 @@ fn fixture_models_global_service_state_and_session_indicators() { fn snapshot_contains_light_bar_tabs_and_active_desktop() { let snapshot = render_snapshot(&fixture_state(), 100, 24).expect("render snapshot"); - assert!(snapshot.contains("svc=online latency=18ms")); + assert!(snapshot.contains("● 18ms [w/r/i 1/1/1] [terminals 3]")); assert!(snapshot.contains("Profile V2")); - assert!(snapshot.contains("Linux OS !")); - assert!(snapshot.contains("repo: github.com/google/capsem")); - assert!(snapshot.contains("< > switch desktop")); + assert!(snapshot.contains("w!:Linux OS")); + assert!(snapshot.contains("github.com/google/capsem")); + assert!(snapshot.contains("duration=47m tokens=38.4k cost=$0.21")); + assert!( + !snapshot.contains("┌"), + "minimal UI should not render boxes" + ); + assert!( + !snapshot.contains("? help"), + "help belongs in a popup, not persistent chrome" + ); } diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 787b70dd..931d097c 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -4,10 +4,12 @@ use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph, Tabs, Wrap}; +use ratatui::widgets::{Paragraph, Wrap}; use ratatui::{Frame, Terminal}; -use crate::model::{AppState, Attention, ServiceStatus, SessionLifecycle, SessionSummary}; +use crate::model::{AppState, ServiceStatus, SessionLifecycle, SessionSummary}; + +const MAX_VISIBLE_TABS: usize = 4; pub fn render(frame: &mut Frame<'_>, state: &AppState) { let root = frame.area(); @@ -15,16 +17,14 @@ pub fn render(frame: &mut Frame<'_>, state: &AppState) { .direction(Direction::Vertical) .constraints([ Constraint::Length(1), - Constraint::Length(3), - Constraint::Min(8), + Constraint::Min(1), Constraint::Length(1), ]) .split(root); - render_light_bar(frame, state, chunks[0]); - render_tabs(frame, state, chunks[1]); - render_active_desktop(frame, state, chunks[2]); - render_footer(frame, chunks[3]); + render_tabs(frame, state, chunks[0]); + render_active_desktop(frame, state, chunks[1]); + render_status_bar(frame, state, chunks[2]); } pub fn render_snapshot(state: &AppState, width: u16, height: u16) -> Result { @@ -34,155 +34,190 @@ pub fn render_snapshot(state: &AppState, width: u16, height: u16) -> Result, state: &AppState, area: Rect) { +fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { let service = &state.service; - let active = state.active_session(); - let service_style = match service.status { - ServiceStatus::Online => Style::default().fg(Color::Green), - ServiceStatus::Reconnecting | ServiceStatus::Stale | ServiceStatus::Degraded => { - Style::default().fg(Color::Yellow) - } - ServiceStatus::Offline | ServiceStatus::Failed => Style::default().fg(Color::Red), - }; - let mut spans = vec![ - Span::styled(" Capsem ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw("svc="), - Span::styled(service.status.label(), service_style), - Span::raw(format!( - " latency={}ms event-age={}ms", - service.latency.as_millis(), - service.last_event_age.as_millis() - )), + let (waiting, running, idle) = session_counts(state); + let mut left = vec![ + Span::raw(" "), + service_dot(service.status, service.latency.as_millis()), + Span::raw(format!(" {}ms ", service.latency.as_millis())), + Span::raw(format!("[w/r/i {waiting}/{running}/{idle}] ")), + Span::raw(format!("[terminals {}]", state.sessions.len())), ]; if let Some(attempt) = service.reconnect_attempt { - spans.push(Span::raw(format!(" reconnect=#{attempt}"))); + left.push(Span::raw(format!(" reconnect#{attempt}"))); } - if let Some(session) = active { - spans.push(Span::raw(" | ")); - spans.push(Span::raw(session_info(session))); - } - frame.render_widget(Paragraph::new(Line::from(spans)), area); + let left_width = spans_width(&left); + let right = state + .active_session() + .map(active_stats) + .unwrap_or_else(|| "no session".to_string()); + let right_width = right.chars().count(); + let area_width = area.width as usize; + let gap = area_width.saturating_sub(left_width + right_width).max(1); + left.push(Span::raw(" ".repeat(gap))); + left.push(Span::raw(right)); + frame.render_widget(Paragraph::new(Line::from(left)), area); } fn render_tabs(frame: &mut Frame<'_>, state: &AppState, area: Rect) { - let titles = state - .sessions - .iter() - .map(tab_title) - .collect::>>(); let active_index = state .sessions .iter() .position(|session| session.id == state.active_session_id) .unwrap_or_default(); - let tabs = Tabs::new(titles) - .select(active_index) - .block(Block::default().borders(Borders::ALL).title("desktops")) - .style(Style::default().fg(Color::Gray)) - .highlight_style( - Style::default() + let visible = visible_tab_range(state.sessions.len(), active_index); + let mut spans = Vec::new(); + if visible.start > 0 { + spans.push(Span::styled(" < ", Style::default().fg(Color::DarkGray))); + } else { + spans.push(Span::raw(" ")); + } + for (offset, session) in state.sessions[visible.clone()].iter().enumerate() { + let index = visible.start + offset; + let mut style = Style::default().fg(Color::Gray); + if index == active_index { + style = Style::default() .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ); - frame.render_widget(tabs, area); + .add_modifier(Modifier::BOLD); + } + spans.push(Span::styled(tab_label(session), style)); + spans.push(Span::raw(" ")); + } + if visible.end < state.sessions.len() { + spans.push(Span::styled("> ", Style::default().fg(Color::DarkGray))); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); } fn render_active_desktop(frame: &mut Frame<'_>, state: &AppState, area: Rect) { let Some(session) = state.active_session() else { - frame.render_widget( - Paragraph::new("No active session. Press n to create one.") - .block(Block::default().borders(Borders::ALL).title("desktop")), - area, - ); + frame.render_widget(Paragraph::new("No active session."), area); return; }; let repo = session.repo_path.as_deref().unwrap_or("no repo"); let branch = session.branch.as_deref().unwrap_or("no branch"); - let attention = attention_text(session); let text = vec![ - Line::from(vec![ - Span::styled(&session.title, Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!(" {}", session.lifecycle.label())), - ]), - Line::from(format!("profile: {}", session.profile)), - Line::from(format!("repo: {repo}")), - Line::from(format!("branch: {branch}")), - Line::from(format!( - "stats: jobs={} events={} cpu={} memory={}MiB", - session.stats.jobs, - session.stats.events, - session.stats.cpu_percent, - session.stats.memory_mb - )), - Line::from(format!("attention: {attention}")), + Line::from(format!("{} {} {}", session.title, repo, branch)), + Line::from(""), + Line::from("$ cargo test -p capsem-tui"), + Line::from("running 2 tests"), + Line::from("test fixture_models_global_service_state_and_session_indicators ... ok"), + Line::from("test snapshot_contains_light_bar_tabs_and_active_desktop ... ok"), Line::from(""), - Line::from("Fixture desktop surface. Real terminal attach and HTTP state arrive in later sub-sprints."), + Line::from( + "Fixture terminal surface. Real attach and HTTP state arrive in later sub-sprints.", + ), ]; - let paragraph = Paragraph::new(text) - .block( - Block::default() - .borders(Borders::ALL) - .title("active desktop"), - ) - .wrap(Wrap { trim: true }); + let paragraph = Paragraph::new(text).wrap(Wrap { trim: true }); frame.render_widget(paragraph, area); } -fn render_footer(frame: &mut Frame<'_>, area: Rect) { - frame.render_widget( - Paragraph::new( - " < > switch desktop ^ sessions / search : command ? help q quit ", - ), - area, - ); +fn service_dot(status: ServiceStatus, latency_ms: u128) -> Span<'static> { + let color = match status { + ServiceStatus::Online if latency_ms < 100 => Color::Green, + ServiceStatus::Online | ServiceStatus::Reconnecting | ServiceStatus::Stale => Color::Yellow, + ServiceStatus::Degraded => Color::Yellow, + ServiceStatus::Offline | ServiceStatus::Failed => Color::Red, + }; + Span::styled("●", Style::default().fg(color)) } -fn tab_title(session: &SessionSummary) -> Line<'static> { +fn session_counts(state: &AppState) -> (usize, usize, usize) { + state + .sessions + .iter() + .fold((0, 0, 0), |mut counts, session| { + match session.lifecycle { + SessionLifecycle::WaitingForInput => counts.0 += 1, + SessionLifecycle::Working => counts.1 += 1, + SessionLifecycle::Idle | SessionLifecycle::Suspended => counts.2 += 1, + SessionLifecycle::Failed => {} + } + counts + }) +} + +fn visible_tab_range(len: usize, active_index: usize) -> std::ops::Range { + if len <= MAX_VISIBLE_TABS { + return 0..len; + } + let half = MAX_VISIBLE_TABS / 2; + let start = active_index + .saturating_sub(half) + .min(len - MAX_VISIBLE_TABS); + start..start + MAX_VISIBLE_TABS +} + +fn tab_label(session: &SessionSummary) -> String { let attention = if session.attention.is_empty() { "" } else { - " !" + "!" }; - let marker = lifecycle_marker(session.lifecycle); - Line::from(format!(" {marker} {}{attention} ", session.title)) + format!( + "{}{}:{}", + lifecycle_marker(session.lifecycle), + attention, + truncate(&session.title, 14) + ) } fn lifecycle_marker(lifecycle: SessionLifecycle) -> &'static str { match lifecycle { - SessionLifecycle::Idle => "idle", - SessionLifecycle::Suspended => "susp", - SessionLifecycle::Working => "work", - SessionLifecycle::WaitingForInput => "wait", - SessionLifecycle::Failed => "fail", + SessionLifecycle::Idle => "i", + SessionLifecycle::Suspended => "s", + SessionLifecycle::Working => "r", + SessionLifecycle::WaitingForInput => "w", + SessionLifecycle::Failed => "f", } } -fn session_info(session: &SessionSummary) -> String { - let repo = session.repo_path.as_deref().unwrap_or("no repo"); - let branch = session.branch.as_deref().unwrap_or("no branch"); +fn active_stats(session: &SessionSummary) -> String { format!( - "session={} profile={} repo={} branch={}", - session.title, session.profile, repo, branch + "duration={} tokens={} cost={}", + format_duration(session.stats.duration), + format_tokens(session.stats.tokens), + format_cost(session.stats.cost_micros) ) } -fn attention_text(session: &SessionSummary) -> String { - if session.attention.is_empty() { - return "none".to_string(); +fn format_duration(duration: std::time::Duration) -> String { + let seconds = duration.as_secs(); + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + if hours > 0 { + format!("{hours}h{minutes:02}m") + } else { + format!("{minutes}m") } - session - .attention - .iter() - .map(|attention| match attention { - Attention::Bell => "bell", - Attention::ApprovalRequired => "approval", - Attention::PolicyDeny => "policy", - Attention::CredentialIssue => "creds", - Attention::StaleData => "stale", - }) - .collect::>() - .join(", ") +} + +fn format_tokens(tokens: u64) -> String { + if tokens >= 1_000 { + format!("{:.1}k", tokens as f64 / 1_000.0) + } else { + tokens.to_string() + } +} + +fn format_cost(cost_micros: u64) -> String { + format!("${:.2}", cost_micros as f64 / 1_000_000.0) +} + +fn truncate(value: &str, max_chars: usize) -> String { + let mut chars = value.chars(); + let truncated = chars.by_ref().take(max_chars).collect::(); + if chars.next().is_some() { + format!("{truncated}...") + } else { + truncated + } +} + +fn spans_width(spans: &[Span<'_>]) -> usize { + spans.iter().map(|span| span.content.chars().count()).sum() } fn buffer_to_string(buffer: &Buffer) -> String { diff --git a/sprints/tui-control/T00-crate-setup-basic-screen.md b/sprints/tui-control/T00-crate-setup-basic-screen.md index d81a5e68..785a25a4 100644 --- a/sprints/tui-control/T00-crate-setup-basic-screen.md +++ b/sprints/tui-control/T00-crate-setup-basic-screen.md @@ -36,6 +36,8 @@ Create `crates/capsem-tui` as a Rust binary/library crate with: - `cargo test -p capsem-tui` passes. - `cargo run -p capsem-tui -- --snapshot` prints a stable standalone screen. - `cargo run -p capsem-tui` opens an interactive fixture demo. +- The screen uses minimal chrome: no boxes, no persistent help footer, a compact + tab strip, quiet terminal space, and one bottom status bar. ## Coverage Ledger diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 332f80de..aaa84221 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -21,6 +21,9 @@ current tool surface. - T00 snapshot at 100x24 confirms the basic layout and also shows light-bar clipping pressure for long repo/session metadata. +- Product correction after visual review: removed boxes and persistent help, + moved global service latency plus cumulative session status into the single + bottom bar, and kept tabs as a compact sliding strip. ## Coverage Ledger From 921b941fca83d19bd4f2be199404fecb7543a151 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 13:37:14 -0400 Subject: [PATCH 03/35] feat: add capsem tui gateway terminal shell --- CHANGELOG.md | 11 + crates/capsem-tui/Cargo.toml | 7 +- crates/capsem-tui/src/app.rs | 173 ++++++ crates/capsem-tui/src/fixture.rs | 16 - crates/capsem-tui/src/gateway_provider.rs | 255 +++++++++ crates/capsem-tui/src/lib.rs | 3 + crates/capsem-tui/src/main.rs | 185 ++++++- crates/capsem-tui/src/terminal.rs | 481 +++++++++++++++++ crates/capsem-tui/src/terminal/tests.rs | 53 ++ crates/capsem-tui/src/tests.rs | 266 +++++++++- crates/capsem-tui/src/ui.rs | 607 ++++++++++++++++++---- justfile | 5 + sprints/tui-control/MASTER.md | 76 ++- sprints/tui-control/tracker.md | 60 ++- 14 files changed, 2028 insertions(+), 170 deletions(-) create mode 100644 crates/capsem-tui/src/app.rs create mode 100644 crates/capsem-tui/src/gateway_provider.rs create mode 100644 crates/capsem-tui/src/terminal.rs create mode 100644 crates/capsem-tui/src/terminal/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b814086..3cea7829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added the initial `capsem-tui` crate with a fixture-backed standalone terminal control screen, global service light-bar state, per-session desktop indicators, and deterministic snapshot rendering for early UI proof. +- Added a `just dev-tui` standalone TUI prototype with two fixture sessions, + SVG snapshot export, and keyboard session switching that does not capture + plain `q`. +- Added live `capsem-tui` gateway wiring against the installed Capsem HTTP + gateway with token auth, periodic refresh, typed session mapping, fixture + fallback, and HTTP provider tests. +- Added active-session terminal WebSocket wiring for `capsem-tui`, including + gateway token reuse, terminal input forwarding, output buffering, resize + messages, and basic ANSI cleanup for the Ratatui surface. +- Added hidden `capsem-tui` overlays for help, active-session statistics, and + the session list so the normal terminal surface stays minimal. ### Changed - Split Google into its own `sprints/google/` meta sprint covering Gmail, diff --git a/crates/capsem-tui/Cargo.toml b/crates/capsem-tui/Cargo.toml index fd1a5ccd..eba2100e 100644 --- a/crates/capsem-tui/Cargo.toml +++ b/crates/capsem-tui/Cargo.toml @@ -17,8 +17,13 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true crossterm.workspace = true +futures.workspace = true ratatui.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tokio-tungstenite = "0.29.0" [lints] workspace = true - diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs new file mode 100644 index 00000000..d8eb6af5 --- /dev/null +++ b/crates/capsem-tui/src/app.rs @@ -0,0 +1,173 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::model::AppState; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AppAction { + Consumed, + Forward, + Exit, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum AppOverlay { + #[default] + None, + Help, + Stats, + Home, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct App { + state: AppState, + active_index: usize, + overlay: AppOverlay, +} + +impl App { + pub fn new(state: AppState) -> Self { + let active_index = state + .sessions + .iter() + .position(|session| session.id == state.active_session_id) + .unwrap_or_default(); + Self { + state, + active_index, + overlay: AppOverlay::None, + } + } + + pub fn state(&self) -> &AppState { + &self.state + } + + pub fn overlay(&self) -> AppOverlay { + self.overlay + } + + pub fn replace_state(&mut self, mut state: AppState) { + let previous_active_id = self.state.active_session_id.clone(); + if state + .sessions + .iter() + .any(|session| session.id == previous_active_id) + { + state.active_session_id = previous_active_id; + } + self.active_index = state + .sessions + .iter() + .position(|session| session.id == state.active_session_id) + .unwrap_or_default(); + self.state = state; + self.sync_active_session(); + } + + pub fn handle_key(&mut self, key: KeyEvent) -> AppAction { + if is_exit_key(key) { + return AppAction::Exit; + } + if self.handle_overlay_key(key) { + return AppAction::Consumed; + } + if is_previous_key(key) { + self.previous_session(); + return AppAction::Consumed; + } + if is_next_key(key) { + self.next_session(); + return AppAction::Consumed; + } + if let Some(index) = select_index(key) { + self.select_session(index); + return AppAction::Consumed; + } + AppAction::Forward + } + + pub fn next_session(&mut self) { + if self.state.sessions.is_empty() { + return; + } + self.active_index = (self.active_index + 1) % self.state.sessions.len(); + self.sync_active_session(); + } + + pub fn previous_session(&mut self) { + if self.state.sessions.is_empty() { + return; + } + self.active_index = if self.active_index == 0 { + self.state.sessions.len() - 1 + } else { + self.active_index - 1 + }; + self.sync_active_session(); + } + + pub fn select_session(&mut self, index: usize) { + if index >= self.state.sessions.len() { + return; + } + self.active_index = index; + self.sync_active_session(); + } + + fn sync_active_session(&mut self) { + let Some(session) = self.state.sessions.get(self.active_index) else { + return; + }; + self.state.active_session_id.clone_from(&session.id); + } + + fn handle_overlay_key(&mut self, key: KeyEvent) -> bool { + let next = match key.code { + KeyCode::F(1) => AppOverlay::Help, + KeyCode::F(2) => AppOverlay::Stats, + KeyCode::F(3) => AppOverlay::Home, + _ => return false, + }; + self.overlay = if self.overlay == next { + AppOverlay::None + } else { + next + }; + true + } +} + +fn is_exit_key(key: KeyEvent) -> bool { + let modifiers = key.modifiers; + matches!( + (key.code, modifiers), + (KeyCode::Char('q'), KeyModifiers::SUPER) + | (KeyCode::Esc, KeyModifiers::CONTROL) + | (KeyCode::F(10), KeyModifiers::NONE) + ) +} + +fn is_previous_key(key: KeyEvent) -> bool { + is_control_key(key.modifiers) && matches!(key.code, KeyCode::Left) +} + +fn is_next_key(key: KeyEvent) -> bool { + is_control_key(key.modifiers) && matches!(key.code, KeyCode::Right) +} + +fn is_control_key(modifiers: KeyModifiers) -> bool { + modifiers.intersects(KeyModifiers::SUPER | KeyModifiers::CONTROL | KeyModifiers::ALT) +} + +fn select_index(key: KeyEvent) -> Option { + if !is_control_key(key.modifiers) { + return None; + } + let KeyCode::Char(value) = key.code else { + return None; + }; + value + .to_digit(10) + .map(|digit| digit.saturating_sub(1) as usize) +} diff --git a/crates/capsem-tui/src/fixture.rs b/crates/capsem-tui/src/fixture.rs index 080dabb8..bb220ece 100644 --- a/crates/capsem-tui/src/fixture.rs +++ b/crates/capsem-tui/src/fixture.rs @@ -59,22 +59,6 @@ pub fn fixture_state() -> AppState { cost_micros: 76_000, }, }, - SessionSummary { - id: "security".to_string(), - title: "Security".to_string(), - repo_path: None, - profile: "high-risk".to_string(), - branch: None, - lifecycle: SessionLifecycle::Suspended, - attention: vec![Attention::ApprovalRequired, Attention::StaleData], - stats: SessionStats { - duration: Duration::from_secs(19 * 60), - jobs: 0, - events: 311, - tokens: 91_250, - cost_micros: 488_000, - }, - }, ], } } diff --git a/crates/capsem-tui/src/gateway_provider.rs b/crates/capsem-tui/src/gateway_provider.rs new file mode 100644 index 00000000..9742c6ee --- /dev/null +++ b/crates/capsem-tui/src/gateway_provider.rs @@ -0,0 +1,255 @@ +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::model::{ + AppState, Attention, ServiceState, ServiceStatus, SessionLifecycle, SessionStats, + SessionSummary, +}; +use crate::provider::StateProvider; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GatewayProvider { + base_url: String, +} + +impl GatewayProvider { + pub fn new(base_url: String) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub fn default_base_url() -> String { + if let Ok(url) = std::env::var("CAPSEM_GATEWAY_URL") { + return url.trim_end_matches('/').to_string(); + } + let port = gateway_port().unwrap_or(19222); + format!("http://127.0.0.1:{port}") + } + + pub async fn load_async(&self) -> Result { + let started = Instant::now(); + let client = reqwest::Client::new(); + let token = fetch_token(&client, &self.base_url).await?; + let status = fetch_status(&client, &self.base_url, &token).await?; + Ok(status_response_to_state(status, started.elapsed())) + } +} + +impl StateProvider for GatewayProvider { + fn load(&self) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build capsem-tui gateway provider runtime")?; + runtime.block_on(self.load_async()) + } +} + +async fn fetch_token(client: &reqwest::Client, base_url: &str) -> Result { + let response = client + .get(format!("{base_url}/token")) + .send() + .await + .context("fetch capsem gateway token")? + .error_for_status() + .context("capsem gateway token request failed")?; + let token: TokenResponse = response + .json() + .await + .context("parse capsem gateway token response")?; + Ok(token.token) +} + +async fn fetch_status( + client: &reqwest::Client, + base_url: &str, + token: &str, +) -> Result { + client + .get(format!("{base_url}/status")) + .bearer_auth(token) + .send() + .await + .context("fetch capsem gateway status")? + .error_for_status() + .context("capsem gateway status request failed")? + .json() + .await + .context("parse capsem gateway status response") +} + +fn gateway_port() -> Option { + let path = run_dir().join("gateway.port"); + let raw = std::fs::read_to_string(path).ok()?; + raw.trim().parse().ok() +} + +fn run_dir() -> PathBuf { + if let Ok(run_dir) = std::env::var("CAPSEM_RUN_DIR") { + return PathBuf::from(run_dir); + } + if let Ok(home) = std::env::var("CAPSEM_HOME") { + return PathBuf::from(home).join("run"); + } + std::env::var("HOME") + .map(|home| PathBuf::from(home).join(".capsem/run")) + .unwrap_or_else(|_| PathBuf::from(".capsem/run")) +} + +fn status_response_to_state(status: StatusResponse, latency: Duration) -> AppState { + let service_status = service_status_from_gateway(&status.service); + let sessions = status + .vms + .into_iter() + .map(vm_response_to_summary) + .collect::>(); + let active_session_id = sessions + .first() + .map(|session| session.id.clone()) + .unwrap_or_default(); + AppState { + service: ServiceState { + status: service_status, + latency, + last_event_age: Duration::ZERO, + reconnect_attempt: None, + }, + active_session_id, + sessions, + } +} + +fn vm_response_to_summary(vm: VmSummary) -> SessionSummary { + let lifecycle = lifecycle_from_status(&vm.status); + let mut attention = attention_from_vm(&vm, lifecycle); + attention.dedup(); + let id = vm.id; + let title = vm.name.unwrap_or_else(|| id.clone()); + let tokens = vm + .total_input_tokens + .unwrap_or_default() + .saturating_add(vm.total_output_tokens.unwrap_or_default()); + SessionSummary { + id, + title, + repo_path: None, + profile: vm.profile_id.unwrap_or_else(|| "default".to_string()), + branch: vm.profile_revision, + lifecycle, + attention, + stats: SessionStats { + duration: Duration::from_secs(vm.uptime_secs.unwrap_or_default()), + jobs: vm.total_tool_calls.unwrap_or_default().min(u16::MAX as u64) as u16, + events: vm + .total_requests + .unwrap_or_default() + .saturating_add(vm.total_file_events.unwrap_or_default()) + .min(u32::MAX as u64) as u32, + tokens, + cost_micros: cost_to_micros(vm.total_estimated_cost), + }, + } +} + +fn service_status_from_gateway(service: &str) -> ServiceStatus { + match service.to_ascii_lowercase().as_str() { + "running" => ServiceStatus::Online, + "unavailable" => ServiceStatus::Degraded, + "failed" => ServiceStatus::Failed, + _ => ServiceStatus::Stale, + } +} + +fn lifecycle_from_status(status: &str) -> SessionLifecycle { + match status.to_ascii_lowercase().as_str() { + "running" => SessionLifecycle::Working, + "suspended" => SessionLifecycle::Suspended, + "defunct" | "failed" => SessionLifecycle::Failed, + "stopped" => SessionLifecycle::Idle, + _ => SessionLifecycle::Idle, + } +} + +fn attention_from_vm(vm: &VmSummary, lifecycle: SessionLifecycle) -> Vec { + let mut attention = Vec::new(); + if matches!(lifecycle, SessionLifecycle::Failed) { + attention.push(Attention::StaleData); + } + if vm.denied_requests.unwrap_or_default() > 0 { + attention.push(Attention::PolicyDeny); + } + if vm.profile_status.as_deref().is_some_and(|status| { + !matches!( + status.to_ascii_lowercase().as_str(), + "ready" | "ok" | "installed" | "active" + ) + }) { + attention.push(Attention::StaleData); + } + attention +} + +fn cost_to_micros(cost: Option) -> u64 { + let Some(cost) = cost else { + return 0; + }; + if !cost.is_finite() || cost <= 0.0 { + return 0; + } + (cost * 1_000_000.0).round().clamp(0.0, u64::MAX as f64) as u64 +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + token: String, +} + +#[derive(Debug, Deserialize)] +struct StatusResponse { + service: String, + vms: Vec, +} + +#[derive(Debug, Deserialize)] +struct VmSummary { + id: String, + #[serde(default)] + name: Option, + status: String, + #[serde(default)] + profile_id: Option, + #[serde(default)] + profile_revision: Option, + #[serde(default)] + profile_status: Option, + #[serde(default)] + uptime_secs: Option, + #[serde(default)] + total_input_tokens: Option, + #[serde(default)] + total_output_tokens: Option, + #[serde(default)] + total_estimated_cost: Option, + #[serde(default)] + total_tool_calls: Option, + #[serde(default)] + total_requests: Option, + #[serde(default)] + denied_requests: Option, + #[serde(default)] + total_file_events: Option, +} + +#[cfg(test)] +pub(crate) fn state_from_status_json_for_test(raw: &str, latency: Duration) -> Result { + let response: StatusResponse = serde_json::from_str(raw)?; + Ok(status_response_to_state(response, latency)) +} diff --git a/crates/capsem-tui/src/lib.rs b/crates/capsem-tui/src/lib.rs index cca7c601..156513ea 100644 --- a/crates/capsem-tui/src/lib.rs +++ b/crates/capsem-tui/src/lib.rs @@ -1,6 +1,9 @@ +pub mod app; pub mod fixture; +pub mod gateway_provider; pub mod model; pub mod provider; +pub mod terminal; pub mod ui; #[cfg(test)] diff --git a/crates/capsem-tui/src/main.rs b/crates/capsem-tui/src/main.rs index 646395fd..510d695e 100644 --- a/crates/capsem-tui/src/main.rs +++ b/crates/capsem-tui/src/main.rs @@ -1,12 +1,16 @@ use std::io; -use std::time::Duration; +use std::time::{Duration, Instant}; -use anyhow::Result; +use anyhow::{Context, Result}; +use capsem_tui::app::{App, AppAction}; use capsem_tui::fixture::FixtureProvider; +use capsem_tui::gateway_provider::GatewayProvider; +use capsem_tui::model::{AppState, ServiceStatus}; use capsem_tui::provider::StateProvider; -use capsem_tui::ui::{render, render_snapshot}; +use capsem_tui::terminal::{key_to_terminal_bytes, TerminalBridge, TerminalSurface}; +use capsem_tui::ui::{render_app, render_snapshot, render_svg_snapshot}; use clap::Parser; -use crossterm::event::{self, Event, KeyCode}; +use crossterm::event::{self, Event}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -25,6 +29,22 @@ struct Cli { #[arg(long)] snapshot: bool, + /// Print a deterministic SVG rendering instead of opening the terminal UI. + #[arg(long)] + snapshot_svg: bool, + + /// Use the built-in two-session fixture instead of the installed Capsem gateway. + #[arg(long)] + fixture: bool, + + /// Capsem gateway base URL. Defaults to installed runtime files, then 127.0.0.1:19222. + #[arg(long)] + gateway_url: Option, + + /// Live gateway refresh interval in milliseconds. + #[arg(long, default_value_t = 1_000)] + refresh_ms: u64, + /// Snapshot width. #[arg(long, default_value_t = 100)] width: u16, @@ -36,25 +56,92 @@ struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); - let provider = FixtureProvider; - let state = provider.load()?; + let state = load_state(&cli)?; + let live_provider = live_provider(&cli); + let terminal_bridge = live_provider + .as_ref() + .map(|provider| TerminalBridge::spawn(provider.base_url().to_string())); + + if cli.snapshot_svg { + println!("{}", render_svg_snapshot(&state, cli.width, cli.height)?); + return Ok(()); + } if cli.snapshot { println!("{}", render_snapshot(&state, cli.width, cli.height)?); return Ok(()); } - run_interactive(&state) + run_interactive( + App::new(state), + live_provider, + terminal_bridge, + cli.refresh_interval(), + ) +} + +fn load_state(cli: &Cli) -> Result { + if cli.fixture { + return FixtureProvider.load(); + } + + let base_url = cli + .gateway_url + .clone() + .unwrap_or_else(GatewayProvider::default_base_url); + match GatewayProvider::new(base_url.clone()).load() { + Ok(state) => Ok(state), + Err(_) if cli.gateway_url.is_none() => { + let mut state = FixtureProvider + .load() + .context("load capsem-tui fallback fixture")?; + state.service.status = ServiceStatus::Offline; + state.service.latency = Duration::ZERO; + state.service.reconnect_attempt = Some(1); + Ok(state) + } + Err(error) => { + Err(error).with_context(|| format!("load capsem gateway state from {base_url}")) + } + } +} + +fn live_provider(cli: &Cli) -> Option { + if cli.fixture { + return None; + } + Some(GatewayProvider::new( + cli.gateway_url + .clone() + .unwrap_or_else(GatewayProvider::default_base_url), + )) } -fn run_interactive(state: &capsem_tui::model::AppState) -> Result<()> { +impl Cli { + fn refresh_interval(&self) -> Duration { + Duration::from_millis(self.refresh_ms.max(100)) + } +} + +fn run_interactive( + mut app: App, + live_provider: Option, + terminal_bridge: Option, + refresh_interval: Duration, +) -> Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let result = run_loop(&mut terminal, state); + let result = run_loop( + &mut terminal, + &mut app, + live_provider, + terminal_bridge, + refresh_interval, + ); disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; @@ -65,14 +152,49 @@ fn run_interactive(state: &capsem_tui::model::AppState) -> Result<()> { fn run_loop( terminal: &mut Terminal>, - state: &capsem_tui::model::AppState, + app: &mut App, + live_provider: Option, + terminal_bridge: Option, + refresh_interval: Duration, ) -> Result<()> { + let mut last_refresh = Instant::now(); + let mut surface = TerminalSurface::new(); + let mut connected_session_id = String::new(); loop { - terminal.draw(|frame| render(frame, state))?; + if let Some(bridge) = &terminal_bridge { + for event in bridge.drain_events() { + surface.apply(event); + } + sync_terminal_connection( + app, + bridge, + &mut connected_session_id, + terminal.size()?.width, + terminal.size()?.height.saturating_sub(1), + ); + } + if last_refresh.elapsed() >= refresh_interval { + refresh_state(app, live_provider.as_ref()); + last_refresh = Instant::now(); + } + terminal.draw(|frame| render_app(frame, app, Some(&surface)))?; if event::poll(Duration::from_millis(250))? { match event::read()? { - Event::Key(key) if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) => { - break; + Event::Key(key) => match app.handle_key(key) { + AppAction::Exit => break, + AppAction::Consumed => {} + AppAction::Forward => { + if let (Some(bridge), Some(bytes)) = + (&terminal_bridge, key_to_terminal_bytes(key)) + { + bridge.input(bytes); + } + } + }, + Event::Resize(width, height) => { + if let Some(bridge) = &terminal_bridge { + bridge.resize(width, height.saturating_sub(1)); + } } _ => {} } @@ -80,3 +202,40 @@ fn run_loop( } Ok(()) } + +fn sync_terminal_connection( + app: &App, + bridge: &TerminalBridge, + connected_session_id: &mut String, + cols: u16, + rows: u16, +) { + let active_id = &app.state().active_session_id; + if active_id.is_empty() || active_id == connected_session_id { + return; + } + bridge.connect(active_id.clone(), cols, rows); + connected_session_id.clone_from(active_id); +} + +fn refresh_state(app: &mut App, provider: Option<&GatewayProvider>) { + let Some(provider) = provider else { + return; + }; + match provider.load() { + Ok(state) => app.replace_state(state), + Err(_) => { + let mut state = app.state().clone(); + state.service.status = ServiceStatus::Offline; + state.service.latency = Duration::ZERO; + state.service.reconnect_attempt = Some( + state + .service + .reconnect_attempt + .unwrap_or_default() + .saturating_add(1), + ); + app.replace_state(state); + } + } +} diff --git a/crates/capsem-tui/src/terminal.rs b/crates/capsem-tui/src/terminal.rs new file mode 100644 index 00000000..ca7448ee --- /dev/null +++ b/crates/capsem-tui/src/terminal.rs @@ -0,0 +1,481 @@ +use std::collections::BTreeMap; +use std::sync::mpsc; +use std::thread; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use futures::{SinkExt, StreamExt}; +use tokio::sync::mpsc as tokio_mpsc; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; + +const MAX_SCROLLBACK_LINES: usize = 2_000; + +#[derive(Debug)] +pub struct TerminalBridge { + commands: tokio_mpsc::UnboundedSender, + events: mpsc::Receiver, +} + +impl TerminalBridge { + pub fn spawn(base_url: String) -> Self { + let (command_tx, command_rx) = tokio_mpsc::unbounded_channel(); + let (event_tx, event_rx) = mpsc::channel(); + thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build capsem-tui terminal runtime"); + runtime.block_on(run_terminal_manager(base_url, command_rx, event_tx)); + }); + Self { + commands: command_tx, + events: event_rx, + } + } + + pub fn connect(&self, session_id: impl Into, cols: u16, rows: u16) { + let _ = self.commands.send(TerminalCommand::Connect { + session_id: session_id.into(), + cols, + rows, + }); + } + + pub fn input(&self, bytes: Vec) { + let _ = self.commands.send(TerminalCommand::Input(bytes)); + } + + pub fn resize(&self, cols: u16, rows: u16) { + let _ = self.commands.send(TerminalCommand::Resize { cols, rows }); + } + + pub fn drain_events(&self) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = self.events.try_recv() { + events.push(event); + } + events + } +} + +impl Drop for TerminalBridge { + fn drop(&mut self) { + let _ = self.commands.send(TerminalCommand::Shutdown); + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum TerminalCommand { + Connect { + session_id: String, + cols: u16, + rows: u16, + }, + Input(Vec), + Resize { + cols: u16, + rows: u16, + }, + Shutdown, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TerminalEvent { + Output { session_id: String, bytes: Vec }, + Status { session_id: String, status: String }, +} + +async fn run_terminal_manager( + base_url: String, + mut commands: tokio_mpsc::UnboundedReceiver, + events: mpsc::Sender, +) { + let mut active_session_id = String::new(); + let mut active_input: Option> = None; + let mut active_task: Option> = None; + + while let Some(command) = commands.recv().await { + match command { + TerminalCommand::Connect { + session_id, + cols, + rows, + } => { + if session_id == active_session_id && active_input.is_some() { + if let Some(input) = &active_input { + let _ = input.send(TerminalInput::Resize { cols, rows }); + } + continue; + } + if let Some(task) = active_task.take() { + task.abort(); + } + let (input_tx, input_rx) = tokio_mpsc::unbounded_channel(); + active_input = Some(input_tx.clone()); + active_session_id.clone_from(&session_id); + let task_base_url = base_url.clone(); + let task_events = events.clone(); + active_task = Some(tokio::spawn(async move { + run_terminal_connection( + task_base_url, + session_id, + cols, + rows, + input_rx, + task_events, + ) + .await; + })); + } + TerminalCommand::Input(bytes) => { + if let Some(input) = &active_input { + let _ = input.send(TerminalInput::Bytes(bytes)); + } + } + TerminalCommand::Resize { cols, rows } => { + if let Some(input) = &active_input { + let _ = input.send(TerminalInput::Resize { cols, rows }); + } + } + TerminalCommand::Shutdown => { + if let Some(task) = active_task.take() { + task.abort(); + } + break; + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum TerminalInput { + Bytes(Vec), + Resize { cols: u16, rows: u16 }, +} + +async fn run_terminal_connection( + base_url: String, + session_id: String, + cols: u16, + rows: u16, + mut input_rx: tokio_mpsc::UnboundedReceiver, + events: mpsc::Sender, +) { + let client = reqwest::Client::new(); + let token = match fetch_token(&client, &base_url).await { + Ok(token) => token, + Err(error) => { + send_status(&events, &session_id, format!("token failed: {error:#}")); + return; + } + }; + let url = terminal_ws_url(&base_url, &session_id, &token); + let (socket, _) = match connect_async(&url).await { + Ok(socket) => socket, + Err(error) => { + send_status(&events, &session_id, format!("connect failed: {error:#}")); + return; + } + }; + send_status(&events, &session_id, "connected"); + let (mut write, mut read) = socket.split(); + let resize = resize_message(cols, rows); + let _ = write.send(Message::Text(resize.into())).await; + + loop { + tokio::select! { + input = input_rx.recv() => { + let Some(input) = input else { + break; + }; + let message = match input { + TerminalInput::Bytes(bytes) => Message::Binary(bytes.into()), + TerminalInput::Resize { cols, rows } => Message::Text(resize_message(cols, rows).into()), + }; + if let Err(error) = write.send(message).await { + send_status(&events, &session_id, format!("send failed: {error:#}")); + break; + } + } + message = read.next() => { + match message { + Some(Ok(Message::Text(text))) => { + let _ = events.send(TerminalEvent::Output { + session_id: session_id.clone(), + bytes: text.to_string().into_bytes(), + }); + } + Some(Ok(Message::Binary(bytes))) => { + let _ = events.send(TerminalEvent::Output { + session_id: session_id.clone(), + bytes: bytes.to_vec(), + }); + } + Some(Ok(Message::Close(_))) | None => { + send_status(&events, &session_id, "disconnected"); + break; + } + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {} + Some(Err(error)) => { + send_status(&events, &session_id, format!("read failed: {error:#}")); + break; + } + } + } + } + } +} + +async fn fetch_token(client: &reqwest::Client, base_url: &str) -> anyhow::Result { + #[derive(serde::Deserialize)] + struct TokenResponse { + token: String, + } + + let token = client + .get(format!("{}/token", base_url.trim_end_matches('/'))) + .send() + .await? + .error_for_status()? + .json::() + .await?; + Ok(token.token) +} + +fn terminal_ws_url(base_url: &str, session_id: &str, token: &str) -> String { + let base = base_url.trim_end_matches('/'); + let ws_base = if let Some(rest) = base.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = base.strip_prefix("http://") { + format!("ws://{rest}") + } else { + base.to_string() + }; + format!( + "{ws_base}/terminal/{}?token={}", + url_encode_component(session_id), + url_encode_component(token) + ) +} + +fn url_encode_component(value: &str) -> String { + value + .bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + vec![byte as char] + } + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() +} + +fn resize_message(cols: u16, rows: u16) -> String { + format!(r#"{{"type":"resize","cols":{cols},"rows":{rows}}}"#) +} + +fn send_status(events: &mpsc::Sender, session_id: &str, status: impl Into) { + let _ = events.send(TerminalEvent::Status { + session_id: session_id.to_string(), + status: status.into(), + }); +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TerminalSurface { + buffers: BTreeMap, +} + +impl TerminalSurface { + pub fn new() -> Self { + Self { + buffers: BTreeMap::new(), + } + } + + pub fn apply(&mut self, event: TerminalEvent) { + match event { + TerminalEvent::Output { session_id, bytes } => { + self.buffer_mut(&session_id).append(&bytes); + } + TerminalEvent::Status { session_id, status } => { + self.buffer_mut(&session_id).status = Some(status); + } + } + } + + pub fn lines_for(&self, session_id: &str, height: usize) -> Vec { + self.buffers + .get(session_id) + .map(|buffer| buffer.visible_lines(height)) + .unwrap_or_default() + } + + pub fn status_for(&self, session_id: &str) -> Option<&str> { + self.buffers + .get(session_id) + .and_then(|buffer| buffer.status.as_deref()) + } + + fn buffer_mut(&mut self, session_id: &str) -> &mut TerminalBuffer { + self.buffers.entry(session_id.to_string()).or_default() + } +} + +impl Default for TerminalSurface { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct TerminalBuffer { + lines: Vec, + parser_state: ParserState, + status: Option, +} + +impl TerminalBuffer { + fn append(&mut self, bytes: &[u8]) { + let text = String::from_utf8_lossy(bytes); + for ch in text.chars() { + self.process_char(ch); + } + self.truncate(); + } + + fn process_char(&mut self, ch: char) { + match self.parser_state { + ParserState::Ground => self.process_ground(ch), + ParserState::Escape => { + self.parser_state = if ch == '[' { + ParserState::Csi(String::new()) + } else { + ParserState::Ground + }; + } + ParserState::Csi(ref mut params) => { + if ('@'..='~').contains(&ch) { + let command = std::mem::take(params); + self.parser_state = ParserState::Ground; + self.apply_csi(&command, ch); + } else { + params.push(ch); + } + } + } + } + + fn process_ground(&mut self, ch: char) { + match ch { + '\u{1b}' => self.parser_state = ParserState::Escape, + '\r' => {} + '\n' => self.lines.push(String::new()), + '\u{8}' | '\u{7f}' => { + let _ = self.current_line().pop(); + } + '\t' => self.current_line().push_str(" "), + ch if !ch.is_control() => self.current_line().push(ch), + _ => {} + } + } + + fn apply_csi(&mut self, params: &str, command: char) { + match command { + 'J' if params.ends_with('2') || params.is_empty() => { + self.lines.clear(); + self.lines.push(String::new()); + } + 'K' => self.current_line().clear(), + _ => {} + } + } + + fn current_line(&mut self) -> &mut String { + if self.lines.is_empty() { + self.lines.push(String::new()); + } + self.lines.last_mut().expect("line exists") + } + + fn visible_lines(&self, height: usize) -> Vec { + let start = self.lines.len().saturating_sub(height); + self.lines[start..].to_vec() + } + + fn truncate(&mut self) { + let overflow = self.lines.len().saturating_sub(MAX_SCROLLBACK_LINES); + if overflow > 0 { + self.lines.drain(..overflow); + } + } +} + +impl Default for TerminalBuffer { + fn default() -> Self { + Self { + lines: vec![String::new()], + parser_state: ParserState::Ground, + status: None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum ParserState { + Ground, + Escape, + Csi(String), +} + +pub fn key_to_terminal_bytes(key: KeyEvent) -> Option> { + if key.modifiers.intersects(KeyModifiers::SUPER) { + return None; + } + if key.modifiers.contains(KeyModifiers::CONTROL) { + return control_key_bytes(key.code); + } + let mut bytes = Vec::new(); + if key.modifiers.contains(KeyModifiers::ALT) { + bytes.push(0x1b); + } + match key.code { + KeyCode::Backspace => bytes.push(0x7f), + KeyCode::Enter => bytes.push(b'\r'), + KeyCode::Left => bytes.extend_from_slice(b"\x1b[D"), + KeyCode::Right => bytes.extend_from_slice(b"\x1b[C"), + KeyCode::Up => bytes.extend_from_slice(b"\x1b[A"), + KeyCode::Down => bytes.extend_from_slice(b"\x1b[B"), + KeyCode::Home => bytes.extend_from_slice(b"\x1b[H"), + KeyCode::End => bytes.extend_from_slice(b"\x1b[F"), + KeyCode::PageUp => bytes.extend_from_slice(b"\x1b[5~"), + KeyCode::PageDown => bytes.extend_from_slice(b"\x1b[6~"), + KeyCode::Tab => bytes.push(b'\t'), + KeyCode::BackTab => bytes.extend_from_slice(b"\x1b[Z"), + KeyCode::Delete => bytes.extend_from_slice(b"\x1b[3~"), + KeyCode::Insert => bytes.extend_from_slice(b"\x1b[2~"), + KeyCode::Esc => bytes.push(0x1b), + KeyCode::Char(ch) => bytes.extend(ch.to_string().as_bytes()), + _ => return None, + } + Some(bytes) +} + +fn control_key_bytes(code: KeyCode) -> Option> { + match code { + KeyCode::Char(ch) if ch.is_ascii_alphabetic() => { + let value = ch.to_ascii_lowercase() as u8 - b'a' + 1; + Some(vec![value]) + } + KeyCode::Char('[') | KeyCode::Esc => Some(vec![0x1b]), + KeyCode::Char(']') => Some(vec![0x1d]), + KeyCode::Char('\\') => Some(vec![0x1c]), + KeyCode::Char('^') => Some(vec![0x1e]), + KeyCode::Char('_') => Some(vec![0x1f]), + KeyCode::Backspace => Some(vec![0x08]), + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/capsem-tui/src/terminal/tests.rs b/crates/capsem-tui/src/terminal/tests.rs new file mode 100644 index 00000000..b86a6783 --- /dev/null +++ b/crates/capsem-tui/src/terminal/tests.rs @@ -0,0 +1,53 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::{key_to_terminal_bytes, TerminalEvent, TerminalSurface}; + +#[test] +fn terminal_surface_keeps_recent_plain_output() { + let mut surface = TerminalSurface::new(); + surface.apply(TerminalEvent::Output { + session_id: "vm-1".into(), + bytes: b"hello\r\nworld".to_vec(), + }); + + assert_eq!(surface.lines_for("vm-1", 2), vec!["hello", "world"]); +} + +#[test] +fn terminal_surface_strips_basic_ansi_sequences() { + let mut surface = TerminalSurface::new(); + surface.apply(TerminalEvent::Output { + session_id: "vm-1".into(), + bytes: b"\x1b[31mred\x1b[0m\n\x1b[2Jfresh".to_vec(), + }); + + assert_eq!(surface.lines_for("vm-1", 3), vec!["fresh"]); +} + +#[test] +fn key_encoding_forwards_agent_input_keys() { + assert_eq!( + key_to_terminal_bytes(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + Some(b"q".to_vec()) + ); + assert_eq!( + key_to_terminal_bytes(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(vec![b'\r']) + ); + assert_eq!( + key_to_terminal_bytes(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), + Some(b"\x1b[C".to_vec()) + ); + assert_eq!( + key_to_terminal_bytes(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + Some(vec![3]) + ); +} + +#[test] +fn key_encoding_does_not_forward_super_shortcuts() { + assert_eq!( + key_to_terminal_bytes(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::SUPER)), + None + ); +} diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index cb85357a..5014a628 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -1,6 +1,11 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::app::{App, AppAction, AppOverlay}; use crate::fixture::fixture_state; +use crate::gateway_provider::{state_from_status_json_for_test, GatewayProvider}; use crate::model::{Attention, ServiceStatus, SessionLifecycle}; -use crate::ui::render_snapshot; +use crate::ui::{render_app_snapshot, render_snapshot}; #[test] fn fixture_models_global_service_state_and_session_indicators() { @@ -22,11 +27,14 @@ fn fixture_models_global_service_state_and_session_indicators() { fn snapshot_contains_light_bar_tabs_and_active_desktop() { let snapshot = render_snapshot(&fixture_state(), 100, 24).expect("render snapshot"); - assert!(snapshot.contains("● 18ms [w/r/i 1/1/1] [terminals 3]")); - assert!(snapshot.contains("Profile V2")); - assert!(snapshot.contains("w!:Linux OS")); - assert!(snapshot.contains("github.com/google/capsem")); - assert!(snapshot.contains("duration=47m tokens=38.4k cost=$0.21")); + assert!(snapshot.contains("● 18ms")); + assert!(snapshot.contains("1 profile-v2")); + assert!(snapshot.contains("2 linux-os!")); + assert!(snapshot.contains("◷ 47m | # 38.4k | $ 0.21")); + assert!( + !snapshot.contains("github.com/google/capsem"), + "repo metadata belongs in a popup or future status segment, not the empty terminal surface" + ); assert!( !snapshot.contains("┌"), "minimal UI should not render boxes" @@ -36,3 +44,249 @@ fn snapshot_contains_light_bar_tabs_and_active_desktop() { "help belongs in a popup, not persistent chrome" ); } + +#[test] +fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { + let mut app = App::new(fixture_state()); + + assert_eq!( + app.handle_key(key(KeyCode::Char('q'), KeyModifiers::NONE)), + AppAction::Forward + ); + assert_eq!(app.state().active_session_id, "profile-v2"); + + assert_eq!( + app.handle_key(key(KeyCode::Right, KeyModifiers::CONTROL)), + AppAction::Consumed + ); + assert_eq!(app.state().active_session_id, "linux-os"); + + assert_eq!( + app.handle_key(key(KeyCode::Left, KeyModifiers::CONTROL)), + AppAction::Consumed + ); + assert_eq!(app.state().active_session_id, "profile-v2"); + + assert_eq!( + app.handle_key(key(KeyCode::Char('2'), KeyModifiers::CONTROL)), + AppAction::Consumed + ); + assert_eq!(app.state().active_session_id, "linux-os"); + + assert_eq!( + app.handle_key(key(KeyCode::Char('c'), KeyModifiers::CONTROL)), + AppAction::Forward + ); + + assert_eq!( + app.handle_key(key(KeyCode::Esc, KeyModifiers::CONTROL)), + AppAction::Exit + ); + assert_eq!( + app.handle_key(key(KeyCode::F(10), KeyModifiers::NONE)), + AppAction::Exit + ); +} + +#[test] +fn refresh_preserves_active_session_when_it_still_exists() { + let mut app = App::new(fixture_state()); + app.select_session(1); + + let mut refreshed = fixture_state(); + refreshed.sessions[1].stats.tokens = 42; + app.replace_state(refreshed); + + assert_eq!(app.state().active_session_id, "linux-os"); + assert_eq!( + app.state() + .active_session() + .expect("active session") + .stats + .tokens, + 42 + ); +} + +#[test] +fn function_keys_toggle_hidden_overlays() { + let mut app = App::new(fixture_state()); + + assert_eq!(app.overlay(), AppOverlay::None); + assert_eq!( + app.handle_key(key(KeyCode::F(1), KeyModifiers::NONE)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::Help); + assert_eq!( + app.handle_key(key(KeyCode::F(2), KeyModifiers::NONE)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::Stats); + assert_eq!( + app.handle_key(key(KeyCode::F(2), KeyModifiers::NONE)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::None); +} + +#[test] +fn stats_overlay_renders_on_demand_without_persistent_help() { + let mut app = App::new(fixture_state()); + app.handle_key(key(KeyCode::F(2), KeyModifiers::NONE)); + + let snapshot = render_app_snapshot(&app, 100, 24).expect("render app snapshot"); + + assert!(snapshot.contains("stats")); + assert!(snapshot.contains("profile-v2")); + assert!(snapshot.contains("tokens")); + assert!( + !render_snapshot(&fixture_state(), 100, 24) + .expect("render base snapshot") + .contains("F1 help"), + "help is hidden until requested" + ); +} + +#[test] +fn gateway_status_json_maps_to_tui_state() { + let state = state_from_status_json_for_test( + gateway_status_body(), + std::time::Duration::from_millis(24), + ) + .expect("parse service list"); + + assert_eq!(state.service.status, ServiceStatus::Online); + assert_eq!(state.service.latency, std::time::Duration::from_millis(24)); + assert_eq!(state.active_session_id, "vm-1"); + assert_eq!(state.sessions.len(), 2); + + let active = &state.sessions[0]; + assert_eq!(active.title, "profile-main"); + assert_eq!(active.profile, "profile-v2"); + assert_eq!(active.lifecycle, SessionLifecycle::Working); + assert_eq!(active.stats.duration, std::time::Duration::from_secs(2840)); + assert_eq!(active.stats.tokens, 38_912); + assert_eq!(active.stats.cost_micros, 215_000); + + let attention = &state.sessions[1]; + assert_eq!(attention.lifecycle, SessionLifecycle::Suspended); + assert!(attention.attention.contains(&Attention::PolicyDeny)); +} + +#[test] +fn malformed_gateway_status_fails_state_mapping() { + let error = state_from_status_json_for_test( + r#"{"service":"running","vms":"not a list"}"#, + std::time::Duration::ZERO, + ) + .expect_err("malformed gateway status should fail"); + + assert!(error.to_string().contains("invalid type")); +} + +#[tokio::test] +async fn gateway_provider_loads_status_over_http_gateway() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test gateway"); + let addr = listener.local_addr().expect("local addr"); + let body = gateway_status_body().to_string(); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let request = read_http_request(&mut stream).await; + if request.contains("GET /token ") { + write_json_response(&mut stream, r#"{"token":"test-token"}"#).await; + } else { + assert!( + request.contains("GET /status "), + "unexpected request: {request:?}" + ); + assert!( + request.contains("authorization: Bearer test-token") + || request.contains("Authorization: Bearer test-token"), + "missing bearer auth: {request:?}" + ); + write_json_response(&mut stream, &body).await; + } + } + }); + + let state = GatewayProvider::new(format!("http://{addr}")) + .load_async() + .await + .expect("load state over gateway"); + + assert_eq!(state.sessions.len(), 2); + assert_eq!(state.sessions[0].id, "vm-1"); + + server.await.expect("server task"); +} + +fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) +} + +async fn read_http_request(stream: &mut tokio::net::TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 256]; + loop { + let bytes_read = stream.read(&mut buffer).await.expect("read request"); + if bytes_read == 0 { + break; + } + request.extend_from_slice(&buffer[..bytes_read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + String::from_utf8_lossy(&request).into_owned() +} + +async fn write_json_response(stream: &mut tokio::net::TcpStream, body: &str) { + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write response"); +} + +fn gateway_status_body() -> &'static str { + r#"{ + "service": "running", + "gateway_version": "test", + "vm_count": 2, + "resource_summary": null, + "vms": [ + { + "id": "vm-1", + "name": "profile-main", + "status": "Running", + "profile_id": "profile-v2", + "profile_revision": "main", + "uptime_secs": 2840, + "total_input_tokens": 30000, + "total_output_tokens": 8912, + "total_estimated_cost": 0.215, + "total_tool_calls": 7, + "total_requests": 11, + "total_file_events": 3 + }, + { + "id": "vm-2", + "status": "Suspended", + "profile_id": "linux-os", + "uptime_secs": 7860, + "total_input_tokens": 10000, + "total_output_tokens": 2900, + "total_estimated_cost": 0.076, + "denied_requests": 1 + } + ] + }"# +} diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 931d097c..2c40f0a6 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -1,142 +1,438 @@ use anyhow::Result; use ratatui::backend::TestBackend; use ratatui::buffer::Buffer; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Paragraph, Wrap}; +use ratatui::widgets::Paragraph; use ratatui::{Frame, Terminal}; -use crate::model::{AppState, ServiceStatus, SessionLifecycle, SessionSummary}; +use crate::app::{App, AppOverlay}; +use crate::model::{AppState, ServiceStatus, SessionSummary}; +use crate::terminal::TerminalSurface; const MAX_VISIBLE_TABS: usize = 4; +const PREVIEW_BG: Color = Color::Rgb(17, 18, 29); +const BAR_BG: Color = Color::Rgb(24, 25, 38); +const TEXT: Color = Color::Rgb(205, 214, 244); +const MUTED: Color = Color::Rgb(127, 137, 180); +const ONLINE: Color = Color::Rgb(166, 227, 161); +const ACTIVE: Color = Color::Rgb(137, 180, 250); +const ATTENTION: Color = Color::Rgb(249, 226, 175); +const BAD: Color = Color::Rgb(243, 139, 168); pub fn render(frame: &mut Frame<'_>, state: &AppState) { + render_with_terminal(frame, state, None); +} + +pub fn render_with_terminal( + frame: &mut Frame<'_>, + state: &AppState, + terminal: Option<&TerminalSurface>, +) { + render_layout(frame, state, terminal, AppOverlay::None); +} + +pub fn render_app(frame: &mut Frame<'_>, app: &App, terminal: Option<&TerminalSurface>) { + render_layout(frame, app.state(), terminal, app.overlay()); +} + +fn render_layout( + frame: &mut Frame<'_>, + state: &AppState, + terminal: Option<&TerminalSurface>, + overlay: AppOverlay, +) { let root = frame.area(); let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ]) + .constraints([Constraint::Min(1), Constraint::Length(1)]) .split(root); - render_tabs(frame, state, chunks[0]); - render_active_desktop(frame, state, chunks[1]); - render_status_bar(frame, state, chunks[2]); + render_terminal_surface(frame, chunks[0], state, terminal); + render_status_bar(frame, state, chunks[1]); + render_overlay(frame, chunks[0], state, overlay); } pub fn render_snapshot(state: &AppState, width: u16, height: u16) -> Result { + Ok(buffer_to_string(&render_buffer(state, width, height)?)) +} + +pub fn render_svg_snapshot(state: &AppState, width: u16, height: u16) -> Result { + Ok(buffer_to_svg(&render_buffer(state, width, height)?)) +} + +pub fn render_app_snapshot(app: &App, width: u16, height: u16) -> Result { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend)?; - terminal.draw(|frame| render(frame, state))?; + terminal.draw(|frame| render_app(frame, app, None))?; Ok(buffer_to_string(terminal.backend().buffer())) } +fn render_buffer(state: &AppState, width: u16, height: u16) -> Result { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|frame| render(frame, state))?; + Ok(terminal.backend().buffer().clone()) +} + fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { let service = &state.service; - let (waiting, running, idle) = session_counts(state); + let active_index = state + .sessions + .iter() + .position(|session| session.id == state.active_session_id) + .unwrap_or_default(); + let base = status_base_style(); + frame.render_widget(Paragraph::new("").style(base), area); + let mut left = vec![ - Span::raw(" "), - service_dot(service.status, service.latency.as_millis()), - Span::raw(format!(" {}ms ", service.latency.as_millis())), - Span::raw(format!("[w/r/i {waiting}/{running}/{idle}] ")), - Span::raw(format!("[terminals {}]", state.sessions.len())), + Span::styled(" ", base), + Span::styled( + service_dot(service.status), + service_style(service.status, service.latency.as_millis()), + ), + Span::styled(format!(" {}ms ", service.latency.as_millis()), base), ]; if let Some(attempt) = service.reconnect_attempt { - left.push(Span::raw(format!(" reconnect#{attempt}"))); + left.push(Span::styled(format!(" reconnect {attempt}"), muted_style())); } - let left_width = spans_width(&left); + let right = state .active_session() - .map(active_stats) - .unwrap_or_else(|| "no session".to_string()); - let right_width = right.chars().count(); - let area_width = area.width as usize; - let gap = area_width.saturating_sub(left_width + right_width).max(1); - left.push(Span::raw(" ".repeat(gap))); - left.push(Span::raw(right)); - frame.render_widget(Paragraph::new(Line::from(left)), area); + .map(active_stats_spans) + .unwrap_or_else(|| vec![Span::styled(" no session ", muted_style())]); + + let left_width = spans_width(&left).min(area.width as usize) as u16; + let right_width = spans_width(&right).min(area.width as usize) as u16; + let center_x = area.x.saturating_add(left_width); + let reserved_width = left_width.saturating_add(right_width); + let center_width = area.width.saturating_sub(reserved_width); + let center = Rect::new(center_x, area.y, center_width, area.height); + + frame.render_widget( + Paragraph::new(Line::from(left)).style(base), + Rect::new(area.x, area.y, left_width, area.height), + ); + + if center_width > 0 { + let tabs = tab_spans(state, active_index, center_width as usize); + frame.render_widget( + Paragraph::new(Line::from(tabs)) + .style(base) + .alignment(Alignment::Center), + center, + ); + } + + let right_x = area + .x + .saturating_add(area.width.saturating_sub(right_width)); + frame.render_widget( + Paragraph::new(Line::from(right)).style(base), + Rect::new(right_x, area.y, right_width, area.height), + ); } -fn render_tabs(frame: &mut Frame<'_>, state: &AppState, area: Rect) { - let active_index = state - .sessions - .iter() - .position(|session| session.id == state.active_session_id) - .unwrap_or_default(); +fn render_terminal_surface( + frame: &mut Frame<'_>, + area: Rect, + state: &AppState, + terminal: Option<&TerminalSurface>, +) { + let Some(terminal) = terminal else { + frame.render_widget(Paragraph::new(""), area); + return; + }; + let active_id = state.active_session_id.as_str(); + let mut lines = terminal + .lines_for(active_id, area.height as usize) + .into_iter() + .map(|line| Line::from(Span::styled(line, Style::default().fg(TEXT)))) + .collect::>(); + if lines.is_empty() { + let status = terminal + .status_for(active_id) + .unwrap_or("waiting for terminal"); + lines.push(Line::from(Span::styled( + format!(" {status}"), + muted_style(), + ))); + } + frame.render_widget(Paragraph::new(lines), area); +} + +fn render_overlay(frame: &mut Frame<'_>, area: Rect, state: &AppState, overlay: AppOverlay) { + if overlay == AppOverlay::None { + return; + } + let popup = centered_rect(area, 72, overlay_height(state, overlay)); + frame.render_widget(Paragraph::new("").style(status_base_style()), popup); + let lines = match overlay { + AppOverlay::Help => help_lines(), + AppOverlay::Stats => stats_lines(state), + AppOverlay::Home => home_lines(state), + AppOverlay::None => Vec::new(), + }; + let inner = Rect::new( + popup.x.saturating_add(2), + popup.y.saturating_add(1), + popup.width.saturating_sub(4), + popup.height.saturating_sub(2), + ); + frame.render_widget(Paragraph::new(lines), inner); +} + +fn centered_rect(area: Rect, width_percent: u16, height: u16) -> Rect { + let width = area.width.saturating_mul(width_percent).saturating_div(100); + let height = height.min(area.height); + Rect::new( + area.x.saturating_add(area.width.saturating_sub(width) / 2), + area.y + .saturating_add(area.height.saturating_sub(height) / 2), + width, + height, + ) +} + +fn overlay_height(state: &AppState, overlay: AppOverlay) -> u16 { + match overlay { + AppOverlay::Help => 8, + AppOverlay::Stats => 9, + AppOverlay::Home => (state.sessions.len() as u16).saturating_add(4).clamp(6, 14), + AppOverlay::None => 0, + } +} + +fn help_lines() -> Vec> { + vec![ + overlay_title("keys"), + overlay_line("F1 help F2 stats F3 sessions"), + overlay_line("Cmd/Ctrl/Alt arrows switch sessions"), + overlay_line("Cmd/Ctrl/Alt number jumps to a session"), + overlay_line("F10 exits; q and Ctrl-C pass through"), + ] +} + +fn stats_lines(state: &AppState) -> Vec> { + let Some(session) = state.active_session() else { + return vec![overlay_title("stats"), overlay_line("no active session")]; + }; + vec![ + overlay_title("stats"), + overlay_pair("session", &session.id), + overlay_pair("profile", &session.profile), + overlay_pair("state", session.lifecycle.label()), + overlay_pair("duration", &format_duration(session.stats.duration)), + overlay_pair("tokens", &format_tokens(session.stats.tokens)), + overlay_pair( + "cost", + &format!("${}", format_cost_amount(session.stats.cost_micros)), + ), + overlay_pair("events", &session.stats.events.to_string()), + ] +} + +fn home_lines(state: &AppState) -> Vec> { + let mut lines = vec![overlay_title("sessions")]; + if state.sessions.is_empty() { + lines.push(overlay_line("no sessions")); + return lines; + } + for (index, session) in state.sessions.iter().take(10).enumerate() { + let active = if session.id == state.active_session_id { + "*" + } else { + " " + }; + lines.push(overlay_line(&format!( + "{active} {} {} {} {}", + index + 1, + truncate(&session.id, 18), + session.lifecycle.label(), + session.profile + ))); + } + lines +} + +fn overlay_title(title: &'static str) -> Line<'static> { + Line::from(Span::styled( + format!(" {title}"), + Style::default() + .fg(ACTIVE) + .bg(BAR_BG) + .add_modifier(Modifier::BOLD), + )) +} + +fn overlay_line(text: &str) -> Line<'static> { + Line::from(Span::styled(text.to_string(), status_base_style())) +} + +fn overlay_pair(label: &'static str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:>8} "), muted_style()), + Span::styled(value.to_string(), status_base_style()), + ]) +} + +fn tab_spans(state: &AppState, active_index: usize, max_width: usize) -> Vec> { let visible = visible_tab_range(state.sessions.len(), active_index); let mut spans = Vec::new(); + let mut used = 0; if visible.start > 0 { - spans.push(Span::styled(" < ", Style::default().fg(Color::DarkGray))); - } else { - spans.push(Span::raw(" ")); + push_budgeted(&mut spans, "< | ", muted_style(), max_width, &mut used); } for (offset, session) in state.sessions[visible.clone()].iter().enumerate() { let index = visible.start + offset; - let mut style = Style::default().fg(Color::Gray); - if index == active_index { - style = Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD); + let separator = if offset == 0 && visible.start == 0 { + "" + } else { + " | " + }; + if !separator.is_empty() + && !push_budgeted( + &mut spans, + separator, + status_base_style(), + max_width, + &mut used, + ) + { + break; + } + + if !push_tab( + &mut spans, + index, + session, + index == active_index, + max_width, + &mut used, + ) { + break; } - spans.push(Span::styled(tab_label(session), style)); - spans.push(Span::raw(" ")); } if visible.end < state.sessions.len() { - spans.push(Span::styled("> ", Style::default().fg(Color::DarkGray))); + let more = " | >"; + if used + more.chars().count() <= max_width { + spans.push(Span::styled(more, muted_style())); + } } - frame.render_widget(Paragraph::new(Line::from(spans)), area); + spans } -fn render_active_desktop(frame: &mut Frame<'_>, state: &AppState, area: Rect) { - let Some(session) = state.active_session() else { - frame.render_widget(Paragraph::new("No active session."), area); - return; - }; +fn push_tab( + spans: &mut Vec>, + index: usize, + session: &SessionSummary, + active: bool, + max_width: usize, + used: &mut usize, +) -> bool { + let tone = TabTone::from_session(session, active); + let number = format!(" {} ", index + 1); + let label = format!( + " {}{} ", + truncate(&session.id, 14), + attention_marker(session) + ); + let width = number.chars().count() + label.chars().count(); + if *used + width > max_width { + return false; + } - let repo = session.repo_path.as_deref().unwrap_or("no repo"); - let branch = session.branch.as_deref().unwrap_or("no branch"); - let text = vec![ - Line::from(format!("{} {} {}", session.title, repo, branch)), - Line::from(""), - Line::from("$ cargo test -p capsem-tui"), - Line::from("running 2 tests"), - Line::from("test fixture_models_global_service_state_and_session_indicators ... ok"), - Line::from("test snapshot_contains_light_bar_tabs_and_active_desktop ... ok"), - Line::from(""), - Line::from( - "Fixture terminal surface. Real attach and HTTP state arrive in later sub-sprints.", - ), - ]; - let paragraph = Paragraph::new(text).wrap(Wrap { trim: true }); - frame.render_widget(paragraph, area); + spans.push(Span::styled( + number, + Style::default() + .fg(BAR_BG) + .bg(tone.color()) + .add_modifier(Modifier::BOLD), + )); + let mut label_style = Style::default().fg(tone.color()).bg(BAR_BG); + if active { + label_style = label_style.add_modifier(Modifier::BOLD); + } + spans.push(Span::styled(label, label_style)); + *used += width; + true +} + +fn push_budgeted( + spans: &mut Vec>, + text: &str, + style: Style, + max_width: usize, + used: &mut usize, +) -> bool { + let width = text.chars().count(); + if *used + width <= max_width { + spans.push(Span::styled(text.to_string(), style)); + *used += width; + return true; + } + false +} + +fn service_dot(status: ServiceStatus) -> &'static str { + match status { + ServiceStatus::Online => "●", + ServiceStatus::Reconnecting | ServiceStatus::Stale | ServiceStatus::Degraded => "◐", + ServiceStatus::Offline | ServiceStatus::Failed => "×", + } } -fn service_dot(status: ServiceStatus, latency_ms: u128) -> Span<'static> { - let color = match status { - ServiceStatus::Online if latency_ms < 100 => Color::Green, - ServiceStatus::Online | ServiceStatus::Reconnecting | ServiceStatus::Stale => Color::Yellow, - ServiceStatus::Degraded => Color::Yellow, - ServiceStatus::Offline | ServiceStatus::Failed => Color::Red, +fn service_style(status: ServiceStatus, latency_ms: u128) -> Style { + let bg = match status { + ServiceStatus::Online if latency_ms < 100 => ONLINE, + ServiceStatus::Online | ServiceStatus::Reconnecting | ServiceStatus::Stale => ATTENTION, + ServiceStatus::Degraded => ATTENTION, + ServiceStatus::Offline | ServiceStatus::Failed => BAD, }; - Span::styled("●", Style::default().fg(color)) + Style::default() + .fg(bg) + .bg(BAR_BG) + .add_modifier(Modifier::BOLD) } -fn session_counts(state: &AppState) -> (usize, usize, usize) { - state - .sessions - .iter() - .fold((0, 0, 0), |mut counts, session| { - match session.lifecycle { - SessionLifecycle::WaitingForInput => counts.0 += 1, - SessionLifecycle::Working => counts.1 += 1, - SessionLifecycle::Idle | SessionLifecycle::Suspended => counts.2 += 1, - SessionLifecycle::Failed => {} - } - counts - }) +fn status_base_style() -> Style { + Style::default().fg(TEXT).bg(BAR_BG) +} + +fn muted_style() -> Style { + Style::default().fg(MUTED).bg(BAR_BG) +} + +fn stats_style() -> Style { + Style::default().fg(TEXT).bg(BAR_BG) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TabTone { + Active, + Attention, + Normal, +} + +impl TabTone { + fn from_session(session: &SessionSummary, active: bool) -> Self { + if active { + Self::Active + } else if session.attention.is_empty() { + Self::Normal + } else { + Self::Attention + } + } + + const fn color(self) -> Color { + match self { + Self::Active => ACTIVE, + Self::Attention => ATTENTION, + Self::Normal => MUTED, + } + } } fn visible_tab_range(len: usize, active_index: usize) -> std::ops::Range { @@ -150,37 +446,24 @@ fn visible_tab_range(len: usize, active_index: usize) -> std::ops::Range start..start + MAX_VISIBLE_TABS } -fn tab_label(session: &SessionSummary) -> String { - let attention = if session.attention.is_empty() { +fn attention_marker(session: &SessionSummary) -> &'static str { + if session.attention.is_empty() { "" } else { "!" - }; - format!( - "{}{}:{}", - lifecycle_marker(session.lifecycle), - attention, - truncate(&session.title, 14) - ) -} - -fn lifecycle_marker(lifecycle: SessionLifecycle) -> &'static str { - match lifecycle { - SessionLifecycle::Idle => "i", - SessionLifecycle::Suspended => "s", - SessionLifecycle::Working => "r", - SessionLifecycle::WaitingForInput => "w", - SessionLifecycle::Failed => "f", } } -fn active_stats(session: &SessionSummary) -> String { - format!( - "duration={} tokens={} cost={}", - format_duration(session.stats.duration), - format_tokens(session.stats.tokens), - format_cost(session.stats.cost_micros) - ) +fn active_stats_spans(session: &SessionSummary) -> Vec> { + vec![ + Span::styled(" ◷ ", muted_style()), + Span::styled(format_duration(session.stats.duration), stats_style()), + Span::styled(" | # ", muted_style()), + Span::styled(format_tokens(session.stats.tokens), stats_style()), + Span::styled(" | $ ", muted_style()), + Span::styled(format_cost_amount(session.stats.cost_micros), stats_style()), + Span::styled(" ", stats_style()), + ] } fn format_duration(duration: std::time::Duration) -> String { @@ -202,8 +485,8 @@ fn format_tokens(tokens: u64) -> String { } } -fn format_cost(cost_micros: u64) -> String { - format!("${:.2}", cost_micros as f64 / 1_000_000.0) +fn format_cost_amount(cost_micros: u64) -> String { + format!("{:.2}", cost_micros as f64 / 1_000_000.0) } fn truncate(value: &str, max_chars: usize) -> String { @@ -220,6 +503,102 @@ fn spans_width(spans: &[Span<'_>]) -> usize { spans.iter().map(|span| span.content.chars().count()).sum() } +fn buffer_to_svg(buffer: &Buffer) -> String { + const CHAR_WIDTH: usize = 11; + const LINE_HEIGHT: usize = 22; + const FONT_SIZE: usize = 16; + const PAD: usize = 16; + + let width = buffer.area.width as usize; + let height = buffer.area.height as usize; + let svg_width = width * CHAR_WIDTH + PAD * 2; + let content_height = height * LINE_HEIGHT + PAD * 2; + let svg_height = svg_width.max(content_height); + let mut svg = String::new(); + svg.push_str(&format!( + "\n" + )); + svg.push_str(&format!( + "\n", + color_hex(PREVIEW_BG) + )); + svg.push_str("\n"); + + for y in 0..height { + for x in 0..width { + let cell = &buffer.content()[y * width + x]; + let bg = if cell.bg == Color::Reset { + PREVIEW_BG + } else { + cell.bg + }; + let rect_x = PAD + x * CHAR_WIDTH; + let rect_y = PAD + y * LINE_HEIGHT; + svg.push_str(&format!( + "\n", + color_hex(bg) + )); + + let symbol = cell.symbol(); + if symbol == " " { + continue; + } + let fg = if cell.fg == Color::Reset { + TEXT + } else { + cell.fg + }; + let weight = if cell.modifier.contains(Modifier::BOLD) { + "700" + } else { + "400" + }; + svg.push_str(&format!( + "{}\n", + color_hex(fg), + escape_xml(symbol) + )); + } + } + svg.push_str("\n"); + svg +} + +fn color_hex(color: Color) -> String { + match color { + Color::Reset => color_hex(TEXT), + Color::Black => "#000000".to_string(), + Color::Red => "#f38ba8".to_string(), + Color::Green => "#a6e3a1".to_string(), + Color::Yellow => "#f9e2af".to_string(), + Color::Blue => "#89b4fa".to_string(), + Color::Magenta => "#cba6f7".to_string(), + Color::Cyan => "#89dceb".to_string(), + Color::Gray => "#bac2de".to_string(), + Color::DarkGray => "#585b70".to_string(), + Color::LightRed => "#f38ba8".to_string(), + Color::LightGreen => "#a6e3a1".to_string(), + Color::LightYellow => "#f9e2af".to_string(), + Color::LightBlue => "#89b4fa".to_string(), + Color::LightMagenta => "#cba6f7".to_string(), + Color::LightCyan => "#89dceb".to_string(), + Color::White => "#ffffff".to_string(), + Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"), + Color::Indexed(index) => { + let gray = index.max(16); + format!("#{gray:02x}{gray:02x}{gray:02x}") + } + } +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + fn buffer_to_string(buffer: &Buffer) -> String { let width = buffer.area.width as usize; buffer diff --git a/justfile b/justfile index 3850048c..745d9870 100644 --- a/justfile +++ b/justfile @@ -252,6 +252,11 @@ ui: _ensure-setup _pnpm-install run-service dev-frontend: _pnpm-install cd frontend && pnpm run dev +# Standalone terminal control-plane prototype. +# Pass extra args after `--`: `just dev-tui -- --snapshot`. +dev-tui *ARGS: + cargo run -p capsem-tui {{ARGS}} + # Build the Tauri desktop app (capsem-app) with a fresh frontend bundle. # IMPORTANT: the Tauri binary embeds frontend/dist at cargo compile time via # tauri::generate_context!(), so rebuilding only the frontend has no effect diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 8d26097c..498aa47b 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -23,21 +23,25 @@ thin client over typed state and actions exposed by Capsem service/gateway APIs. | ID | Status | Scope | Proof | | --- | --- | --- | --- | | T00 | Done | Crate setup and standalone fixture screen | `cargo test -p capsem-tui`; snapshot command | -| T01 | Not Started | Terminal screenshot/snapshot proof path | buffer snapshots; screenshot/export strategy | -| T02 | Not Started | Multiple desktop tabs and per-session indicators | render tests for active/attention states | -| T03 | Not Started | Keyboard controls and focus/modal ownership | key-sequence tests | -| T04 | Not Started | Help, full statistics, and new-session screens | screen render tests | -| T05 | Not Started | Home/resume screen with profile/session list | fixture render tests | -| T06 | Not Started | Typed HTTP/service model inventory and API gaps | service schema gap doc | -| T07 | Not Started | Wire local gateway/service read-only state | fake + live gateway tests | +| T01 | Done | Terminal screenshot/snapshot proof path | `--snapshot-svg`; rendered PNG inspection | +| T02 | Done | Multiple desktop tabs and per-session indicators | render tests for active/attention states | +| T03 | Done | Keyboard controls and focus/modal ownership | key-sequence tests | +| T04 | Done | Help, full statistics, and new-session screens | overlay render tests | +| T05 | Done | Home/resume screen with profile/session list | fixture render tests | +| T06 | Done | Typed HTTP gateway model inventory and API gaps | `/status` schema mapped into TUI model | +| T07 | Done | Wire installed gateway read-only state | HTTP provider test + live snapshot | | T08 | Not Started | Safe service control actions | confirmation/action tests | | T09 | Not Started | Remote transport readiness | reconnect/event cursor tests | +| T10 | In Progress | Active terminal WebSocket surface | terminal buffer/input tests | ## Current Decision -Wire a provider boundary early, but do not wire real Capsem HTTP behavior until -the standalone shell, tab model, keyboard model, and overlays are testable. The -first crate uses fixture state through the same interface later used by HTTP. +The standalone shell is now wired read-only to the installed Capsem HTTP +gateway. Default mode discovers the installed gateway port from runtime files, +falls back to `http://127.0.0.1:19222`, fetches `/token`, and then polls +authenticated `GET /status`; `--fixture` keeps the two-session demo path for +visual iteration; `--gateway-url` turns connection failures into explicit errors +for focused gateway testing. ## T00 Closeout @@ -47,11 +51,55 @@ first crate uses fixture state through the same interface later used by HTTP. - Deferred real screenshot export/CAPSEM MCP capture to T01 because the current exposed Capsem MCP tool surface does not include terminal screenshot capture. +## T01-T03 Closeout + +- Added `--snapshot-svg` style-preserving export for visual proof. +- Reworked the standalone shell into a tmux-like single status bar with global + service state on the left, numbered session tabs in the center, and active + session stats on the right. +- Added a typed app controller for session switching. +- Kept plain `q` and Ctrl-C available for the agent/terminal stream; standalone + exits on F10, Ctrl-Esc, or Cmd-Q when the terminal delivers it, and switches + sessions with modified arrow keys or modified tab numbers. +- Added `just dev-tui` for direct local playback. + +## T04-T05 Closeout + +- Added hidden overlays for help, active-session statistics, and session/home + list. +- Kept the normal terminal surface clean; overlays only appear through function + keys and toggle back off with the same key. +- Scoped the home screen to existing sessions for this slice. New-session + creation remains part of the later safe-action sprint because it mutates + service state. + +## T06-T07 Closeout + +- Inventoried the existing gateway status model instead of adding a parallel + API: `StatusResponse { vms }` already carries ID, name, status, profile, + uptime, token/cost counters, policy-deny counters, and file event/request + counters. +- Added a typed `GatewayProvider` that reads the installed HTTP gateway. +- Mapped service status into TUI lifecycle state: running, suspended, stopped, + failed/defunct. +- Mapped existing gateway-exposed deny and stale profile status into attention + markers. +- Added periodic interactive refresh while preserving the selected tab when it + still exists after reload. +- Added active-session terminal WebSocket wiring through the gateway: + `/token`, `/terminal/{id}?token=...`, resize messages, terminal input + forwarding, and output buffering for the Ratatui surface. +- Kept richer missing state explicit for future API work: waiting-for-input, + terminal bell, per-session repo/path metadata, security/enforcement/detection + totals, and event cursor semantics are not invented by the TUI. + ## Testing Gate - Unit/contract: required for state and render logic. -- Functional: standalone demo and text/snapshot render output. -- Adversarial: malformed/missing fields once the HTTP model exists. -- E2E/VM: deferred until service wiring begins. -- Telemetry: deferred until service wiring begins. +- Functional: standalone demo, text snapshot, and SVG render output. +- Adversarial: malformed gateway status and authenticated provider parsing. +- E2E/VM: live empty-service snapshot covered; live multi-VM terminal session + proof remains open. +- Telemetry: mapped from current counters; event stream/cursor semantics remain + open. - Performance: frame/render timing deferred until interactive loop exists. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index aaa84221..10526681 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -1,6 +1,6 @@ # Sprint: TUI Control -## Active Sub-Sprint: T00 +## Active Sub-Sprint: T10 - [x] Create meta sprint and T00 plan. - [x] Add `capsem-tui` workspace crate. @@ -9,6 +9,23 @@ - [x] Add snapshot/text render proof. - [x] Add changelog entry. - [x] Run focused tests. +- [x] Add SVG snapshot proof path. +- [x] Rework status bar to tmux-style left/center/right layout. +- [x] Add two-session standalone fixture for local playback. +- [x] Add keyboard session switching without capturing plain `q`. +- [x] Add `just dev-tui` recipe. +- [x] Add hidden help, stats, and sessions overlays. +- [x] Inventory existing gateway `/status` model for TUI state. +- [x] Add typed gateway provider over installed HTTP gateway. +- [x] Map lifecycle, attention, uptime, token, cost, job, and event counters. +- [x] Add malformed status and authenticated HTTP provider tests. +- [x] Refresh live state periodically in interactive mode. +- [x] Add active-session terminal WebSocket client through the gateway. +- [x] Forward terminal input keys while keeping app navigation shortcuts owned + by the shell. +- [x] Preserve plain `q` and Ctrl-C for the agent/terminal stream. +- [x] Render active terminal output in the main Ratatui surface. +- [x] Add terminal buffer, ANSI cleanup, and key encoding tests. - [ ] Commit functional milestone. ## Notes @@ -24,12 +41,43 @@ - Product correction after visual review: removed boxes and persistent help, moved global service latency plus cumulative session status into the single bottom bar, and kept tabs as a compact sliding strip. +- Product correction after tmux reference review: removed aggregate VM status + counts from the left, kept only service health/latency, colored only the + active tab and attention tabs, and tied tab label color to the number tone. +- Keyboard policy: plain `q` and Ctrl-C belong to the agent/terminal stream, so + the standalone shell exits via F10, Ctrl-Esc, or Cmd-Q if the terminal emits + it. +- Default `dev-tui` reads the installed HTTP gateway when available. It uses + `CAPSEM_GATEWAY_URL` when set, otherwise the installed runtime + `gateway.port`, otherwise `http://127.0.0.1:19222`. +- `--fixture` forces the two-session visual demo, and `--gateway-url ` is + strict for gateway debugging. +- Current service state on this machine responds but has no live sessions, so + the live snapshot correctly renders `no session`. +- API gaps still open for later sub-sprints: waiting-for-input status, terminal + bell, per-session repo/path metadata, security/enforcement/detection totals + on gateway `/status`, event cursoring, and remote transport latency/error + details. +- Terminal WebSocket slice is intentionally active-session only for now. It + connects the selected tab and reconnects when the selected tab changes; idle + background session multiplexing belongs in the later virtual-desktop sprint. +- New-session creation is deliberately not in the hidden sessions overlay yet + because it mutates service state and belongs with safe action confirmation. ## Coverage Ledger - Unit/contract: `cargo test -p capsem-tui`. -- Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`. -- Adversarial: deferred until API/input parsing. -- E2E/VM: deferred until service wiring. -- Telemetry: deferred until service wiring. -- Performance: deferred until interactive frame loop work. +- Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`; + `cargo run -p capsem-tui -- --fixture --snapshot --width 120 --height 30`; + `cargo run -p capsem-tui -- --fixture --snapshot-svg --width 120 --height 30`; + `just dev-tui`. +- Gateway wiring: `GatewayProvider::load_async` authenticated HTTP mock test + plus live local snapshot through the installed gateway. +- Terminal wiring: `TerminalSurface` output/ANSI tests and key-encoding tests. +- Overlay wiring: function-key state tests and stats overlay render test. +- Adversarial: malformed gateway status mapping. +- E2E/VM: live multi-session terminal proof still open; current installed + gateway has no live sessions. +- Telemetry: current gateway `/status` counters mapped; event-stream semantics + still open. +- Performance: not measured yet. From 92a9992f4555fe713b0d57c3a0e0456825f2b4f3 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 14:28:13 -0400 Subject: [PATCH 04/35] feat: add capsem mcp terminal snapshot --- CHANGELOG.md | 3 + crates/capsem-mcp/src/main.rs | 129 +++++++++++++++++++++++++++++++++ crates/capsem-mcp/src/tests.rs | 73 +++++++++++++++++++ skills/dev-mcp/SKILL.md | 1 + sprints/tui-control/MASTER.md | 2 + sprints/tui-control/tracker.md | 6 ++ 6 files changed, 214 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cea7829..e8a7625e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 messages, and basic ANSI cleanup for the Ratatui surface. - Added hidden `capsem-tui` overlays for help, active-session statistics, and the session list so the normal terminal surface stays minimal. +- Added `capsem_terminal_snapshot` to the Capsem MCP server so agents can + inspect a session terminal/log surface through MCP with ANSI cleanup, grep, + source selection, and tailing. ### Changed - Split Google into its own `sprints/google/` meta sprint covering Gmail, diff --git a/crates/capsem-mcp/src/main.rs b/crates/capsem-mcp/src/main.rs index ff123665..7d4842ae 100644 --- a/crates/capsem-mcp/src/main.rs +++ b/crates/capsem-mcp/src/main.rs @@ -62,6 +62,106 @@ fn grep_log_fields(val: &mut Value, pattern: &str) { } } +fn terminal_snapshot_from_logs( + val: Value, + params: &TerminalSnapshotParams, +) -> Result { + if let Some(err) = val.get("error").and_then(|e| e.as_str()) { + return Err(err.to_string()); + } + let source = params.source.as_deref().unwrap_or("serial"); + let raw = match source { + "serial" => val + .get("serial_logs") + .or_else(|| val.get("logs")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + "process" => val + .get("process_logs") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + "combined" => { + let serial = val + .get("serial_logs") + .or_else(|| val.get("logs")) + .and_then(Value::as_str) + .unwrap_or_default(); + let process = val + .get("process_logs") + .and_then(Value::as_str) + .unwrap_or_default(); + format!("{serial}\n{process}") + } + other => { + return Err(format!( + "unsupported source {other:?}; expected serial, process, or combined" + )); + } + }; + + let mut text = strip_terminal_control_sequences(&raw); + if let Some(pattern) = ¶ms.grep { + text = grep_lines(&text, pattern); + } + let tail = params.tail.unwrap_or(80); + text = tail_lines(&text, tail); + let lines = text.lines().map(str::to_string).collect::>(); + Ok(serde_json::to_string_pretty(&json!({ + "id": params.id, + "source": source, + "line_count": lines.len(), + "lines": lines, + "text": text, + })) + .unwrap_or_else(|_| "{\"text\":\"\"}".to_string())) +} + +fn strip_terminal_control_sequences(input: &str) -> String { + #[derive(Clone, Copy)] + enum State { + Ground, + Escape, + Csi, + Osc, + OscEscape, + } + + let mut output = String::with_capacity(input.len()); + let mut state = State::Ground; + for ch in input.chars() { + match state { + State::Ground => match ch { + '\u{1b}' => state = State::Escape, + '\r' => {} + '\n' | '\t' => output.push(ch), + ch if ch.is_control() => {} + ch => output.push(ch), + }, + State::Escape => match ch { + '[' => state = State::Csi, + ']' => state = State::Osc, + _ => state = State::Ground, + }, + State::Csi => { + if ('@'..='~').contains(&ch) { + state = State::Ground; + } + } + State::Osc => match ch { + '\u{7}' => state = State::Ground, + '\u{1b}' => state = State::OscEscape, + _ => {} + }, + State::OscEscape => { + state = State::Ground; + } + } + } + output +} + /// Render a service response to the shape MCP expects. /// /// If the underlying request failed, returns the error string. Otherwise, @@ -550,6 +650,17 @@ struct LogsParams { tail: Option, } +#[derive(Debug, Serialize, Deserialize, JsonSchema, Default)] +struct TerminalSnapshotParams { + id: String, + /// Source to render: serial (default), process, or combined. + source: Option, + /// Case-insensitive substring filter applied after ANSI cleanup. + grep: Option, + /// Return only the last N rendered terminal lines. Default 80. + tail: Option, +} + #[derive(Debug, Serialize, Deserialize, JsonSchema, Default)] struct ServiceLogsParams { /// Case-insensitive substring filter applied to each log line @@ -697,6 +808,24 @@ impl CapsemHandler { } } + #[tool( + name = "capsem_terminal_snapshot", + description = "Render a text snapshot of a session terminal/log surface from service logs. Uses serial logs by default, strips ANSI/control sequences, supports grep and tail. This is the MCP-visible terminal inspection tool for agents when an image screenshot is not needed." + )] + async fn terminal_snapshot( + &self, + Parameters(params): Parameters, + ) -> Result { + match self + .client + .request::("GET", &format!("/logs/{}", params.id), None) + .await + { + Ok(val) => terminal_snapshot_from_logs(val, ¶ms), + Err(e) => Err(e.to_string()), + } + } + #[tool( name = "capsem_service_logs", description = "Get the latest capsem-service logs (last ~100KB). Use grep to filter lines, tail to limit to last N lines" diff --git a/crates/capsem-mcp/src/tests.rs b/crates/capsem-mcp/src/tests.rs index 8df5416b..c17a33d1 100644 --- a/crates/capsem-mcp/src/tests.rs +++ b/crates/capsem-mcp/src/tests.rs @@ -451,6 +451,7 @@ fn tool_router_registers_all_tools() { "capsem_purge", "capsem_run", "capsem_vm_logs", + "capsem_terminal_snapshot", "capsem_service_logs", "capsem_version", "capsem_fork", @@ -473,6 +474,21 @@ fn tool_router_registers_all_tools() { ); } +#[test] +fn terminal_snapshot_tool_description_mentions_terminal_inspection() { + let tools = CapsemHandler::tool_router(); + let all_tools = tools.list_all(); + let tool = all_tools + .iter() + .find(|tool| tool.name == "capsem_terminal_snapshot") + .expect("capsem_terminal_snapshot registered"); + let description = tool.description.as_deref().unwrap_or_default(); + assert!( + description.contains("terminal") && description.contains("ANSI"), + "terminal snapshot description should explain terminal inspection: {description}" + ); +} + #[test] fn vm_logs_tool_description_mentions_security_logs() { let tools = CapsemHandler::tool_router(); @@ -488,6 +504,63 @@ fn vm_logs_tool_description_mentions_security_logs() { ); } +#[test] +fn terminal_snapshot_strips_ansi_and_tails_serial_log() { + let params = TerminalSnapshotParams { + id: "vm-1".into(), + tail: Some(2), + ..Default::default() + }; + let out = terminal_snapshot_from_logs( + json!({ + "serial_logs": "boot\n\u{1b}[31mred\u{1b}[0m\nready\r\nprompt$ " + }), + ¶ms, + ) + .unwrap(); + let json: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(json["id"], "vm-1"); + assert_eq!(json["source"], "serial"); + assert_eq!(json["lines"][0], "ready"); + assert_eq!(json["lines"][1], "prompt$ "); + assert!( + !json["text"].as_str().unwrap().contains('\u{1b}'), + "ANSI escapes should be stripped" + ); +} + +#[test] +fn terminal_snapshot_supports_grep_and_process_source() { + let params = TerminalSnapshotParams { + id: "vm-1".into(), + source: Some("process".into()), + grep: Some("error".into()), + tail: Some(10), + }; + let out = terminal_snapshot_from_logs( + json!({ + "serial_logs": "serial ok", + "process_logs": "info\nerror: failed\nwarn" + }), + ¶ms, + ) + .unwrap(); + let json: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(json["source"], "process"); + assert_eq!(json["lines"], json!(["error: failed"])); +} + +#[test] +fn terminal_snapshot_rejects_unknown_source() { + let params = TerminalSnapshotParams { + id: "vm-1".into(), + source: Some("cosmic".into()), + ..Default::default() + }; + let err = terminal_snapshot_from_logs(json!({"serial_logs": "ok"}), ¶ms).unwrap_err(); + assert!(err.contains("unsupported source")); +} + // ----------------------------------------------------------------------- // Handler server info // ----------------------------------------------------------------------- diff --git a/skills/dev-mcp/SKILL.md b/skills/dev-mcp/SKILL.md index afa0544e..51e0fcef 100644 --- a/skills/dev-mcp/SKILL.md +++ b/skills/dev-mcp/SKILL.md @@ -30,6 +30,7 @@ When the capsem MCP server is configured in your AI CLI, you have direct VM cont | `capsem_read_file` | id, path | Read file content from guest | | `capsem_write_file` | id, path, content | Write file into guest | | `capsem_vm_logs` | id, grep?, tail? | Serial + process logs. grep filters lines, tail limits to last N. | +| `capsem_terminal_snapshot` | id, source?, grep?, tail? | Render a text snapshot of a session terminal/log surface from serial/process logs with ANSI cleanup. | | `capsem_service_logs` | grep?, tail? | Service daemon logs (last ~100KB). grep + tail filters. | | `capsem_inspect_schema` | -- | session.db CREATE TABLE statements | | `capsem_inspect` | id, sql | Raw SQL against session.db | diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 498aa47b..16f27609 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -89,6 +89,8 @@ for focused gateway testing. - Added active-session terminal WebSocket wiring through the gateway: `/token`, `/terminal/{id}?token=...`, resize messages, terminal input forwarding, and output buffering for the Ratatui surface. +- Added `capsem_terminal_snapshot` to the host MCP server so agents can inspect + session terminal/log state without needing an image-capable screenshot tool. - Kept richer missing state explicit for future API work: waiting-for-input, terminal bell, per-session repo/path metadata, security/enforcement/detection totals, and event cursor semantics are not invented by the TUI. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 10526681..55affe9e 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -26,6 +26,7 @@ - [x] Preserve plain `q` and Ctrl-C for the agent/terminal stream. - [x] Render active terminal output in the main Ratatui surface. - [x] Add terminal buffer, ANSI cleanup, and key encoding tests. +- [x] Add `capsem_terminal_snapshot` MCP tool for session terminal inspection. - [ ] Commit functional milestone. ## Notes @@ -63,6 +64,9 @@ background session multiplexing belongs in the later virtual-desktop sprint. - New-session creation is deliberately not in the hidden sessions overlay yet because it mutates service state and belongs with safe action confirmation. +- MCP terminal inspection is now a text snapshot from service logs, not a + bitmap screenshot. It is enough for agent debugging and works through the + existing service log contract. ## Coverage Ledger @@ -74,6 +78,8 @@ - Gateway wiring: `GatewayProvider::load_async` authenticated HTTP mock test plus live local snapshot through the installed gateway. - Terminal wiring: `TerminalSurface` output/ANSI tests and key-encoding tests. +- MCP wiring: `capsem_terminal_snapshot` router registration and rendering + tests. - Overlay wiring: function-key state tests and stats overlay render test. - Adversarial: malformed gateway status mapping. - E2E/VM: live multi-session terminal proof still open; current installed From ec473982aaa5640fe43273030ca3f0503b34a28b Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 16:04:03 -0400 Subject: [PATCH 05/35] feat: add confirmed capsem tui service actions --- CHANGELOG.md | 3 + crates/capsem-tui/src/app.rs | 114 ++++++++++++++- crates/capsem-tui/src/fixture.rs | 3 + crates/capsem-tui/src/gateway_provider.rs | 120 +++++++++++++++ crates/capsem-tui/src/main.rs | 81 ++++++++++- crates/capsem-tui/src/model.rs | 2 + crates/capsem-tui/src/tests.rs | 170 +++++++++++++++++++++- crates/capsem-tui/src/ui.rs | 44 +++++- sprints/tui-control/MASTER.md | 22 ++- sprints/tui-control/tracker.md | 28 +++- 10 files changed, 562 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11158ac6..bad54c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 messages, and basic ANSI cleanup for the Ratatui surface. - Added hidden `capsem-tui` overlays for help, active-session statistics, and the session list so the normal terminal surface stays minimal. +- Added confirmed `capsem-tui` service actions for creating, resuming, + suspending, stopping, and deleting sessions through the installed HTTP + gateway without blocking the terminal UI. - Added `capsem_terminal_snapshot` to the Capsem MCP server so agents can inspect a session terminal/log surface through MCP with ANSI cleanup, grep, source selection, and tailing. diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index d8eb6af5..eb6aecd8 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -1,11 +1,12 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use crate::model::AppState; +use crate::model::{AppState, SessionLifecycle}; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum AppAction { Consumed, Forward, + Invoke(ControlAction), Exit, } @@ -16,6 +17,38 @@ pub enum AppOverlay { Help, Stats, Home, + Confirm, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ControlAction { + CreateEphemeral, + Resume { name: String }, + Suspend { id: String }, + Stop { id: String }, + Delete { id: String }, +} + +impl ControlAction { + pub const fn label(&self) -> &'static str { + match self { + Self::CreateEphemeral => "create", + Self::Resume { .. } => "resume", + Self::Suspend { .. } => "suspend", + Self::Stop { .. } => "stop", + Self::Delete { .. } => "delete", + } + } + + pub fn target(&self) -> &str { + match self { + Self::CreateEphemeral => "new ephemeral session", + Self::Resume { name } + | Self::Suspend { id: name } + | Self::Stop { id: name } + | Self::Delete { id: name } => name, + } + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -23,6 +56,7 @@ pub struct App { state: AppState, active_index: usize, overlay: AppOverlay, + pending_action: Option, } impl App { @@ -36,6 +70,7 @@ impl App { state, active_index, overlay: AppOverlay::None, + pending_action: None, } } @@ -47,7 +82,12 @@ impl App { self.overlay } + pub fn pending_action(&self) -> Option<&ControlAction> { + self.pending_action.as_ref() + } + pub fn replace_state(&mut self, mut state: AppState) { + state.service.control_message = self.state.service.control_message.clone(); let previous_active_id = self.state.active_session_id.clone(); if state .sessions @@ -65,13 +105,25 @@ impl App { self.sync_active_session(); } + pub fn set_control_message(&mut self, message: impl Into) { + self.state.service.control_message = Some(message.into()); + } + pub fn handle_key(&mut self, key: KeyEvent) -> AppAction { if is_exit_key(key) { return AppAction::Exit; } + if let Some(action) = self.handle_pending_action_key(key) { + return action; + } if self.handle_overlay_key(key) { return AppAction::Consumed; } + if let Some(action) = self.control_action_for_key(key) { + self.pending_action = Some(action); + self.overlay = AppOverlay::Confirm; + return AppAction::Consumed; + } if is_previous_key(key) { self.previous_session(); return AppAction::Consumed; @@ -134,8 +186,66 @@ impl App { } else { next }; + self.pending_action = None; true } + + fn handle_pending_action_key(&mut self, key: KeyEvent) -> Option { + let pending = self.pending_action.clone()?; + match key.code { + KeyCode::Enter => { + self.pending_action = None; + self.overlay = AppOverlay::None; + Some(AppAction::Invoke(pending)) + } + KeyCode::Esc => { + self.pending_action = None; + self.overlay = AppOverlay::None; + Some(AppAction::Consumed) + } + _ => Some(AppAction::Consumed), + } + } + + fn control_action_for_key(&self, key: KeyEvent) -> Option { + match key.code { + KeyCode::F(4) => Some(ControlAction::CreateEphemeral), + KeyCode::F(5) => self.active_resume_action(), + KeyCode::F(6) => self.active_suspend_action(), + KeyCode::F(7) => self.active_id().map(|id| ControlAction::Stop { id }), + KeyCode::F(8) => self.active_id().map(|id| ControlAction::Delete { id }), + _ => None, + } + } + + fn active_resume_action(&self) -> Option { + let session = self.state.active_session()?; + if !matches!( + session.lifecycle, + SessionLifecycle::Idle | SessionLifecycle::Suspended | SessionLifecycle::Failed + ) { + return None; + } + Some(ControlAction::Resume { + name: session.id.clone(), + }) + } + + fn active_suspend_action(&self) -> Option { + let session = self.state.active_session()?; + if !session.persistent || !matches!(session.lifecycle, SessionLifecycle::Working) { + return None; + } + Some(ControlAction::Suspend { + id: session.id.clone(), + }) + } + + fn active_id(&self) -> Option { + self.state + .active_session() + .map(|session| session.id.clone()) + } } fn is_exit_key(key: KeyEvent) -> bool { diff --git a/crates/capsem-tui/src/fixture.rs b/crates/capsem-tui/src/fixture.rs index bb220ece..6516e625 100644 --- a/crates/capsem-tui/src/fixture.rs +++ b/crates/capsem-tui/src/fixture.rs @@ -24,6 +24,7 @@ pub fn fixture_state() -> AppState { latency: Duration::from_millis(18), last_event_age: Duration::from_millis(240), reconnect_attempt: None, + control_message: None, }, active_session_id: "profile-v2".to_string(), sessions: vec![ @@ -33,6 +34,7 @@ pub fn fixture_state() -> AppState { repo_path: Some("github.com/google/capsem".to_string()), profile: "corp-default".to_string(), branch: Some("codex/tui-control".to_string()), + persistent: true, lifecycle: SessionLifecycle::Working, attention: Vec::new(), stats: SessionStats { @@ -49,6 +51,7 @@ pub fn fixture_state() -> AppState { repo_path: Some("github.com/google/capsem-linux".to_string()), profile: "linux-builder".to_string(), branch: Some("resume-fix".to_string()), + persistent: true, lifecycle: SessionLifecycle::WaitingForInput, attention: vec![Attention::Bell], stats: SessionStats { diff --git a/crates/capsem-tui/src/gateway_provider.rs b/crates/capsem-tui/src/gateway_provider.rs index 9742c6ee..a0046c0f 100644 --- a/crates/capsem-tui/src/gateway_provider.rs +++ b/crates/capsem-tui/src/gateway_provider.rs @@ -4,6 +4,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use serde::Deserialize; +use crate::app::ControlAction; use crate::model::{ AppState, Attention, ServiceState, ServiceStatus, SessionLifecycle, SessionStats, SessionSummary, @@ -41,6 +42,20 @@ impl GatewayProvider { let status = fetch_status(&client, &self.base_url, &token).await?; Ok(status_response_to_state(status, started.elapsed())) } + + pub fn invoke(&self, action: &ControlAction) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build capsem-tui gateway action runtime")?; + runtime.block_on(self.invoke_async(action)) + } + + pub async fn invoke_async(&self, action: &ControlAction) -> Result { + let client = reqwest::Client::new(); + let token = fetch_token(&client, &self.base_url).await?; + invoke_action(&client, &self.base_url, &token, action).await + } } impl StateProvider for GatewayProvider { @@ -121,6 +136,7 @@ fn status_response_to_state(status: StatusResponse, latency: Duration) -> AppSta latency, last_event_age: Duration::ZERO, reconnect_attempt: None, + control_message: None, }, active_session_id, sessions, @@ -143,6 +159,7 @@ fn vm_response_to_summary(vm: VmSummary) -> SessionSummary { repo_path: None, profile: vm.profile_id.unwrap_or_else(|| "default".to_string()), branch: vm.profile_revision, + persistent: vm.persistent, lifecycle, attention, stats: SessionStats { @@ -207,6 +224,107 @@ fn cost_to_micros(cost: Option) -> u64 { (cost * 1_000_000.0).round().clamp(0.0, u64::MAX as f64) as u64 } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActionOutcome { + pub message: String, +} + +async fn invoke_action( + client: &reqwest::Client, + base_url: &str, + token: &str, + action: &ControlAction, +) -> Result { + match action { + ControlAction::CreateEphemeral => { + let response = client + .post(join_url(base_url, &["provision"])?) + .bearer_auth(token) + .json(&serde_json::json!({ "persistent": false })) + .send() + .await + .context("create capsem session")?; + let body = response_json(response).await?; + let id = body + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("session"); + Ok(ActionOutcome { + message: format!("created {id}"), + }) + } + ControlAction::Resume { name } => { + post_empty(client, base_url, token, &["resume", name]).await?; + Ok(ActionOutcome { + message: format!("resumed {name}"), + }) + } + ControlAction::Suspend { id } => { + post_empty(client, base_url, token, &["suspend", id]).await?; + Ok(ActionOutcome { + message: format!("suspended {id}"), + }) + } + ControlAction::Stop { id } => { + post_empty(client, base_url, token, &["stop", id]).await?; + Ok(ActionOutcome { + message: format!("stopped {id}"), + }) + } + ControlAction::Delete { id } => { + let response = client + .delete(join_url(base_url, &["delete", id])?) + .bearer_auth(token) + .send() + .await + .with_context(|| format!("delete capsem session {id}"))?; + response_json(response).await?; + Ok(ActionOutcome { + message: format!("deleted {id}"), + }) + } + } +} + +async fn post_empty( + client: &reqwest::Client, + base_url: &str, + token: &str, + path_segments: &[&str], +) -> Result { + let response = client + .post(join_url(base_url, path_segments)?) + .bearer_auth(token) + .send() + .await + .with_context(|| format!("post gateway action /{}", path_segments.join("/")))?; + response_json(response).await +} + +async fn response_json(response: reqwest::Response) -> Result { + let status = response.status(); + let text = response + .text() + .await + .context("read gateway action response body")?; + if !status.is_success() { + return Err(anyhow::anyhow!("gateway action failed ({status}): {text}")); + } + if text.trim().is_empty() { + return Ok(serde_json::json!({})); + } + serde_json::from_str(&text).context("parse gateway action response") +} + +fn join_url(base_url: &str, path_segments: &[&str]) -> Result { + let mut url = reqwest::Url::parse(&format!("{}/", base_url.trim_end_matches('/'))) + .context("parse capsem gateway base URL")?; + url.path_segments_mut() + .map_err(|_| anyhow::anyhow!("capsem gateway URL cannot be a base"))? + .extend(path_segments); + Ok(url) +} + #[derive(Debug, Deserialize)] struct TokenResponse { token: String, @@ -225,6 +343,8 @@ struct VmSummary { name: Option, status: String, #[serde(default)] + persistent: bool, + #[serde(default)] profile_id: Option, #[serde(default)] profile_revision: Option, diff --git a/crates/capsem-tui/src/main.rs b/crates/capsem-tui/src/main.rs index 510d695e..fd97b26c 100644 --- a/crates/capsem-tui/src/main.rs +++ b/crates/capsem-tui/src/main.rs @@ -1,10 +1,12 @@ use std::io; +use std::sync::mpsc; +use std::thread; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; -use capsem_tui::app::{App, AppAction}; +use capsem_tui::app::{App, AppAction, ControlAction}; use capsem_tui::fixture::FixtureProvider; -use capsem_tui::gateway_provider::GatewayProvider; +use capsem_tui::gateway_provider::{ActionOutcome, GatewayProvider}; use capsem_tui::model::{AppState, ServiceStatus}; use capsem_tui::provider::StateProvider; use capsem_tui::terminal::{key_to_terminal_bytes, TerminalBridge, TerminalSurface}; @@ -138,8 +140,9 @@ fn run_interactive( let result = run_loop( &mut terminal, &mut app, - live_provider, + live_provider.clone(), terminal_bridge, + live_provider.map(ControlBridge::spawn), refresh_interval, ); @@ -155,12 +158,34 @@ fn run_loop( app: &mut App, live_provider: Option, terminal_bridge: Option, + control_bridge: Option, refresh_interval: Duration, ) -> Result<()> { let mut last_refresh = Instant::now(); let mut surface = TerminalSurface::new(); let mut connected_session_id = String::new(); loop { + if let Some(bridge) = &control_bridge { + let mut should_refresh = false; + for event in bridge.drain_events() { + match event { + ControlEvent::Started(label) => { + app.set_control_message(format!("{label}...")); + } + ControlEvent::Finished(Ok(outcome)) => { + app.set_control_message(outcome.message); + should_refresh = true; + } + ControlEvent::Finished(Err(error)) => { + app.set_control_message(error); + should_refresh = true; + } + } + } + if should_refresh { + refresh_state(app, live_provider.as_ref()); + } + } if let Some(bridge) = &terminal_bridge { for event in bridge.drain_events() { surface.apply(event); @@ -183,6 +208,13 @@ fn run_loop( Event::Key(key) => match app.handle_key(key) { AppAction::Exit => break, AppAction::Consumed => {} + AppAction::Invoke(action) => { + if let Some(bridge) = &control_bridge { + bridge.invoke(action); + } else { + app.set_control_message("fixture action ignored"); + } + } AppAction::Forward => { if let (Some(bridge), Some(bytes)) = (&terminal_bridge, key_to_terminal_bytes(key)) @@ -203,6 +235,49 @@ fn run_loop( Ok(()) } +struct ControlBridge { + commands: mpsc::Sender, + events: mpsc::Receiver, +} + +impl ControlBridge { + fn spawn(provider: GatewayProvider) -> Self { + let (command_tx, command_rx) = mpsc::channel::(); + let (event_tx, event_rx) = mpsc::channel::(); + thread::spawn(move || { + while let Ok(action) = command_rx.recv() { + let label = action.label().to_string(); + let _ = event_tx.send(ControlEvent::Started(label)); + let result = provider + .invoke(&action) + .map_err(|error| format!("{} failed: {error}", action.label())); + let _ = event_tx.send(ControlEvent::Finished(result)); + } + }); + Self { + commands: command_tx, + events: event_rx, + } + } + + fn invoke(&self, action: ControlAction) { + let _ = self.commands.send(action); + } + + fn drain_events(&self) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = self.events.try_recv() { + events.push(event); + } + events + } +} + +enum ControlEvent { + Started(String), + Finished(std::result::Result), +} + fn sync_terminal_connection( app: &App, bridge: &TerminalBridge, diff --git a/crates/capsem-tui/src/model.rs b/crates/capsem-tui/src/model.rs index c92af9a3..ebbcb119 100644 --- a/crates/capsem-tui/src/model.rs +++ b/crates/capsem-tui/src/model.rs @@ -21,6 +21,7 @@ pub struct ServiceState { pub latency: Duration, pub last_event_age: Duration, pub reconnect_attempt: Option, + pub control_message: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -53,6 +54,7 @@ pub struct SessionSummary { pub repo_path: Option, pub profile: String, pub branch: Option, + pub persistent: bool, pub lifecycle: SessionLifecycle, pub attention: Vec, pub stats: SessionStats, diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 5014a628..e0f36eed 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -1,7 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use crate::app::{App, AppAction, AppOverlay}; +use crate::app::{App, AppAction, AppOverlay, ControlAction}; use crate::fixture::fixture_state; use crate::gateway_provider::{state_from_status_json_for_test, GatewayProvider}; use crate::model::{Attention, ServiceStatus, SessionLifecycle}; @@ -130,6 +130,89 @@ fn function_keys_toggle_hidden_overlays() { assert_eq!(app.overlay(), AppOverlay::None); } +#[test] +fn control_keys_require_confirmation_before_invoking_service_actions() { + let mut app = App::new(fixture_state()); + + assert_eq!( + app.handle_key(key(KeyCode::F(7), KeyModifiers::NONE)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::Confirm); + assert_eq!( + app.pending_action(), + Some(&ControlAction::Stop { + id: "profile-v2".to_string() + }) + ); + + assert_eq!( + app.handle_key(key(KeyCode::Char('x'), KeyModifiers::NONE)), + AppAction::Consumed, + "confirmation overlay owns keys until confirmed or cancelled" + ); + + assert_eq!( + app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE)), + AppAction::Invoke(ControlAction::Stop { + id: "profile-v2".to_string() + }) + ); + assert_eq!(app.overlay(), AppOverlay::None); + assert_eq!(app.pending_action(), None); +} + +#[test] +fn resume_action_is_only_available_for_stopped_or_suspended_sessions() { + let mut app = App::new(fixture_state()); + + assert_eq!( + app.handle_key(key(KeyCode::F(5), KeyModifiers::NONE)), + AppAction::Forward, + "running active session should not map F5 to resume" + ); + + let mut state = fixture_state(); + state.active_session_id = "linux-os".to_string(); + state.sessions[1].lifecycle = SessionLifecycle::Suspended; + app = App::new(state); + + assert_eq!( + app.handle_key(key(KeyCode::F(5), KeyModifiers::NONE)), + AppAction::Consumed + ); + assert_eq!( + app.pending_action(), + Some(&ControlAction::Resume { + name: "linux-os".to_string() + }) + ); +} + +#[test] +fn suspend_action_requires_persistent_running_session() { + let mut app = App::new(fixture_state()); + assert_eq!( + app.handle_key(key(KeyCode::F(6), KeyModifiers::NONE)), + AppAction::Consumed + ); + assert_eq!( + app.pending_action(), + Some(&ControlAction::Suspend { + id: "profile-v2".to_string() + }) + ); + + let mut state = fixture_state(); + state.sessions[0].persistent = false; + app = App::new(state); + assert_eq!( + app.handle_key(key(KeyCode::F(6), KeyModifiers::NONE)), + AppAction::Forward, + "ephemeral sessions cannot be suspended through the service" + ); +} + #[test] fn stats_overlay_renders_on_demand_without_persistent_help() { let mut app = App::new(fixture_state()); @@ -224,6 +307,83 @@ async fn gateway_provider_loads_status_over_http_gateway() { server.await.expect("server task"); } +#[tokio::test] +async fn gateway_provider_invokes_stop_over_authenticated_gateway() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test gateway"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let request = read_http_request(&mut stream).await; + if request.contains("GET /token ") { + write_json_response(&mut stream, r#"{"token":"test-token"}"#).await; + } else { + assert!( + request.contains("POST /stop/vm-1 "), + "unexpected request: {request:?}" + ); + assert!( + request.contains("authorization: Bearer test-token") + || request.contains("Authorization: Bearer test-token"), + "missing bearer auth: {request:?}" + ); + write_json_response(&mut stream, r#"{"success":true}"#).await; + } + } + }); + + let outcome = GatewayProvider::new(format!("http://{addr}")) + .invoke_async(&ControlAction::Stop { + id: "vm-1".to_string(), + }) + .await + .expect("invoke stop"); + + assert_eq!(outcome.message, "stopped vm-1"); + server.await.expect("server task"); +} + +#[tokio::test] +async fn gateway_provider_surfaces_action_error_body() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test gateway"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let request = read_http_request(&mut stream).await; + if request.contains("GET /token ") { + write_json_response(&mut stream, r#"{"token":"test-token"}"#).await; + } else { + assert!( + request.contains("DELETE /delete/vm-1 "), + "unexpected request: {request:?}" + ); + write_response( + &mut stream, + "500 Internal Server Error", + r#"{"error":"boom"}"#, + ) + .await; + } + } + }); + + let error = GatewayProvider::new(format!("http://{addr}")) + .invoke_async(&ControlAction::Delete { + id: "vm-1".to_string(), + }) + .await + .expect_err("delete should fail"); + + assert!(error.to_string().contains("500")); + assert!(error.to_string().contains("boom")); + server.await.expect("server task"); +} + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { KeyEvent::new(code, modifiers) } @@ -245,8 +405,12 @@ async fn read_http_request(stream: &mut tokio::net::TcpStream) -> String { } async fn write_json_response(stream: &mut tokio::net::TcpStream, body: &str) { + write_response(stream, "200 OK", body).await; +} + +async fn write_response(stream: &mut tokio::net::TcpStream, status: &str, body: &str) { let response = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", body.len(), body ); @@ -267,6 +431,7 @@ fn gateway_status_body() -> &'static str { "id": "vm-1", "name": "profile-main", "status": "Running", + "persistent": true, "profile_id": "profile-v2", "profile_revision": "main", "uptime_secs": 2840, @@ -280,6 +445,7 @@ fn gateway_status_body() -> &'static str { { "id": "vm-2", "status": "Suspended", + "persistent": true, "profile_id": "linux-os", "uptime_secs": 7860, "total_input_tokens": 10000, diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 2c40f0a6..b8912c12 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -7,7 +7,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::{Frame, Terminal}; -use crate::app::{App, AppOverlay}; +use crate::app::{App, AppOverlay, ControlAction}; use crate::model::{AppState, ServiceStatus, SessionSummary}; use crate::terminal::TerminalSurface; @@ -30,11 +30,17 @@ pub fn render_with_terminal( state: &AppState, terminal: Option<&TerminalSurface>, ) { - render_layout(frame, state, terminal, AppOverlay::None); + render_layout(frame, state, terminal, AppOverlay::None, None); } pub fn render_app(frame: &mut Frame<'_>, app: &App, terminal: Option<&TerminalSurface>) { - render_layout(frame, app.state(), terminal, app.overlay()); + render_layout( + frame, + app.state(), + terminal, + app.overlay(), + app.pending_action(), + ); } fn render_layout( @@ -42,6 +48,7 @@ fn render_layout( state: &AppState, terminal: Option<&TerminalSurface>, overlay: AppOverlay, + pending_action: Option<&ControlAction>, ) { let root = frame.area(); let chunks = Layout::default() @@ -51,7 +58,7 @@ fn render_layout( render_terminal_surface(frame, chunks[0], state, terminal); render_status_bar(frame, state, chunks[1]); - render_overlay(frame, chunks[0], state, overlay); + render_overlay(frame, chunks[0], state, overlay, pending_action); } pub fn render_snapshot(state: &AppState, width: u16, height: u16) -> Result { @@ -97,6 +104,12 @@ fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { if let Some(attempt) = service.reconnect_attempt { left.push(Span::styled(format!(" reconnect {attempt}"), muted_style())); } + if let Some(message) = &service.control_message { + left.push(Span::styled( + format!(" {}", truncate(message, 28)), + muted_style(), + )); + } let right = state .active_session() @@ -162,7 +175,13 @@ fn render_terminal_surface( frame.render_widget(Paragraph::new(lines), area); } -fn render_overlay(frame: &mut Frame<'_>, area: Rect, state: &AppState, overlay: AppOverlay) { +fn render_overlay( + frame: &mut Frame<'_>, + area: Rect, + state: &AppState, + overlay: AppOverlay, + pending_action: Option<&ControlAction>, +) { if overlay == AppOverlay::None { return; } @@ -172,6 +191,7 @@ fn render_overlay(frame: &mut Frame<'_>, area: Rect, state: &AppState, overlay: AppOverlay::Help => help_lines(), AppOverlay::Stats => stats_lines(state), AppOverlay::Home => home_lines(state), + AppOverlay::Confirm => confirm_lines(pending_action), AppOverlay::None => Vec::new(), }; let inner = Rect::new( @@ -200,6 +220,7 @@ fn overlay_height(state: &AppState, overlay: AppOverlay) -> u16 { AppOverlay::Help => 8, AppOverlay::Stats => 9, AppOverlay::Home => (state.sessions.len() as u16).saturating_add(4).clamp(6, 14), + AppOverlay::Confirm => 6, AppOverlay::None => 0, } } @@ -208,12 +229,25 @@ fn help_lines() -> Vec> { vec![ overlay_title("keys"), overlay_line("F1 help F2 stats F3 sessions"), + overlay_line("F4 new F5 resume F6 suspend F7 stop F8 delete"), overlay_line("Cmd/Ctrl/Alt arrows switch sessions"), overlay_line("Cmd/Ctrl/Alt number jumps to a session"), overlay_line("F10 exits; q and Ctrl-C pass through"), ] } +fn confirm_lines(action: Option<&ControlAction>) -> Vec> { + let Some(action) = action else { + return vec![overlay_title("confirm"), overlay_line("no pending action")]; + }; + vec![ + overlay_title("confirm"), + overlay_pair("action", action.label()), + overlay_pair("target", action.target()), + overlay_line("Enter confirms; Esc cancels"), + ] +} + fn stats_lines(state: &AppState) -> Vec> { let Some(session) = state.active_session() else { return vec![overlay_title("stats"), overlay_line("no active session")]; diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 16f27609..507efd99 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -30,18 +30,19 @@ thin client over typed state and actions exposed by Capsem service/gateway APIs. | T05 | Done | Home/resume screen with profile/session list | fixture render tests | | T06 | Done | Typed HTTP gateway model inventory and API gaps | `/status` schema mapped into TUI model | | T07 | Done | Wire installed gateway read-only state | HTTP provider test + live snapshot | -| T08 | Not Started | Safe service control actions | confirmation/action tests | +| T08 | Done | Safe service control actions | confirmation/action tests | | T09 | Not Started | Remote transport readiness | reconnect/event cursor tests | | T10 | In Progress | Active terminal WebSocket surface | terminal buffer/input tests | ## Current Decision -The standalone shell is now wired read-only to the installed Capsem HTTP +The standalone shell is now wired to the installed Capsem HTTP gateway. Default mode discovers the installed gateway port from runtime files, falls back to `http://127.0.0.1:19222`, fetches `/token`, and then polls -authenticated `GET /status`; `--fixture` keeps the two-session demo path for -visual iteration; `--gateway-url` turns connection failures into explicit errors -for focused gateway testing. +authenticated `GET /status`. Safe mutating actions go through the same gateway +with a confirmation overlay and a background worker. `--fixture` keeps the +two-session demo path for visual iteration; `--gateway-url` turns connection +failures into explicit errors for focused gateway testing. ## T00 Closeout @@ -91,17 +92,22 @@ for focused gateway testing. forwarding, and output buffering for the Ratatui surface. - Added `capsem_terminal_snapshot` to the host MCP server so agents can inspect session terminal/log state without needing an image-capable screenshot tool. +- Added confirmed create/resume/suspend/stop/delete actions through the + installed gateway, with background execution so long service operations do + not block terminal rendering. - Kept richer missing state explicit for future API work: waiting-for-input, terminal bell, per-session repo/path metadata, security/enforcement/detection totals, and event cursor semantics are not invented by the TUI. ## Testing Gate -- Unit/contract: required for state and render logic. +- Unit/contract: required for state, render, confirmation, and action wiring. - Functional: standalone demo, text snapshot, and SVG render output. -- Adversarial: malformed gateway status and authenticated provider parsing. +- Adversarial: malformed gateway status, authenticated provider parsing, and + action error propagation. - E2E/VM: live empty-service snapshot covered; live multi-VM terminal session - proof remains open. + proof remains open because the installed service is missing the exact pinned + `initrd.img` and `assets.capsem.dev` does not resolve on this host. - Telemetry: mapped from current counters; event stream/cursor semantics remain open. - Performance: frame/render timing deferred until interactive loop exists. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 55affe9e..261c805f 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -27,7 +27,11 @@ - [x] Render active terminal output in the main Ratatui surface. - [x] Add terminal buffer, ANSI cleanup, and key encoding tests. - [x] Add `capsem_terminal_snapshot` MCP tool for session terminal inspection. -- [ ] Commit functional milestone. +- [x] Add confirmed create/resume/suspend/stop/delete actions through the + installed HTTP gateway. +- [x] Run live installed-gateway empty-service snapshot. +- [ ] Run live two-session terminal proof. +- [x] Commit functional milestone. ## Notes @@ -67,23 +71,37 @@ - MCP terminal inspection is now a text snapshot from service logs, not a bitmap screenshot. It is enough for agent debugging and works through the existing service log contract. +- Safe service actions are now active behind a confirmation overlay. F4 creates + an ephemeral session, F5 resumes stopped/suspended sessions, F6 suspends the + active session, F7 stops it, and F8 deletes it. Action calls run on a + background worker so long suspend/stop/provision paths do not freeze terminal + rendering. +- Live VM proof is currently blocked by installed asset readiness, not by the + TUI route. `capsem_create(name=tui-proof-a)` fails because + `/Users/elie/.capsem/assets/arm64/initrd-b2be4ef1b9033569.img` is missing + and `assets.capsem.dev` does not resolve on this host while other DNS + lookups such as `github.com` do resolve. ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui`. +- Unit/contract: `cargo test -p capsem-tui` (18 tests). - Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`; `cargo run -p capsem-tui -- --fixture --snapshot --width 120 --height 30`; `cargo run -p capsem-tui -- --fixture --snapshot-svg --width 120 --height 30`; `just dev-tui`. - Gateway wiring: `GatewayProvider::load_async` authenticated HTTP mock test plus live local snapshot through the installed gateway. +- Service actions: confirmed action key tests plus authenticated mock gateway + tests for successful stop and surfaced service error bodies. - Terminal wiring: `TerminalSurface` output/ANSI tests and key-encoding tests. - MCP wiring: `capsem_terminal_snapshot` router registration and rendering tests. - Overlay wiring: function-key state tests and stats overlay render test. -- Adversarial: malformed gateway status mapping. -- E2E/VM: live multi-session terminal proof still open; current installed - gateway has no live sessions. +- Adversarial: malformed gateway status mapping; action error response body + surfaced to the status bar instead of being swallowed. +- E2E/VM: live installed-gateway empty-service snapshot works; live + multi-session terminal proof is blocked by missing installed `initrd.img` and + asset-host DNS failure. - Telemetry: current gateway `/status` counters mapped; event-stream semantics still open. - Performance: not measured yet. From 6823cf1fd25dfc5cd96a288d57db9159d26ea959 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 16:59:53 -0400 Subject: [PATCH 06/35] feat: package capsem tui binary --- CHANGELOG.md | 2 ++ justfile | 5 +++-- scripts/build-pkg.sh | 2 +- scripts/capture-install-status.py | 1 + scripts/deb-postinst.sh | 2 +- scripts/pkg-scripts/postinstall | 2 +- scripts/repack-deb.sh | 3 ++- scripts/simulate-install.sh | 2 +- scripts/verify_deb_payload.py | 1 + tests/capsem-install/conftest.py | 2 ++ tests/test_package_scripts.py | 5 +++-- tests/test_release_workflow_policy.py | 3 +++ tests/test_repack_deb.py | 1 + 13 files changed, 22 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bad54c01..d953e09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added confirmed `capsem-tui` service actions for creating, resuming, suspending, stopping, and deleting sessions through the installed HTTP gateway without blocking the terminal UI. +- Added `capsem-tui` to local install/package payloads so the TUI is available + from `~/.capsem/bin/capsem-tui` after installation. - Added `capsem_terminal_snapshot` to the Capsem MCP server so agents can inspect a session terminal/log surface through MCP with ANSI cleanup, grep, source selection, and tailing. diff --git a/justfile b/justfile index 1b12ab92..5509aeb4 100644 --- a/justfile +++ b/justfile @@ -55,11 +55,11 @@ service_binary := "target/debug/capsem-service" process_binary := "target/debug/capsem-process" mcp_binary := "target/debug/capsem-mcp" gateway_binary := "target/debug/capsem-gateway" -host_binaries := "target/debug/capsem target/debug/capsem-service target/debug/capsem-process target/debug/capsem-mcp target/debug/capsem-mcp-aggregator target/debug/capsem-mcp-builtin target/debug/capsem-gateway target/debug/capsem-tray" +host_binaries := "target/debug/capsem target/debug/capsem-service target/debug/capsem-process target/debug/capsem-mcp target/debug/capsem-mcp-aggregator target/debug/capsem-mcp-builtin target/debug/capsem-gateway target/debug/capsem-tray target/debug/capsem-tui" assets_dir := "assets" default_asset_profile := "config/profiles/base/coding.profile.toml" entitlements := "entitlements.plist" -host_crates := "-p capsem-service -p capsem-process -p capsem -p capsem-mcp -p capsem-mcp-aggregator -p capsem-mcp-builtin -p capsem-gateway -p capsem-tray" +host_crates := "-p capsem-service -p capsem-process -p capsem -p capsem-mcp -p capsem-mcp-aggregator -p capsem-mcp-builtin -p capsem-gateway -p capsem-tray -p capsem-tui" # Stamp version as 1.2.{unix_timestamp} in Cargo.toml, tauri.conf.json, and pyproject.toml. _stamp-version: @@ -1105,6 +1105,7 @@ install: _pnpm-install _stamp-version _check-assets assert_executable "$HOME/.capsem/bin/capsem-mcp-builtin" assert_executable "$HOME/.capsem/bin/capsem-gateway" assert_executable "$HOME/.capsem/bin/capsem-tray" + assert_executable "$HOME/.capsem/bin/capsem-tui" if [ ! -f "$HOME/.capsem/assets/manifest.json" ]; then echo "ERROR: installed asset manifest missing" >&2 exit 1 diff --git a/scripts/build-pkg.sh b/scripts/build-pkg.sh index 76d101cc..387e7da3 100755 --- a/scripts/build-pkg.sh +++ b/scripts/build-pkg.sh @@ -62,7 +62,7 @@ cp -R "$APP_PATH" "$WORK_DIR/payload/Applications/Capsem.app" # Companion binaries SHARE_DIR="$WORK_DIR/payload/usr/local/share/capsem" mkdir -p "$SHARE_DIR/bin" -for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-admin; do +for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-tui capsem-admin; do src="$BIN_DIR/$bin" if [ -f "$src" ]; then cp "$src" "$SHARE_DIR/bin/$bin" diff --git a/scripts/capture-install-status.py b/scripts/capture-install-status.py index f9127474..7391c674 100755 --- a/scripts/capture-install-status.py +++ b/scripts/capture-install-status.py @@ -25,6 +25,7 @@ "capsem-mcp-builtin", "capsem-gateway", "capsem-tray", + "capsem-tui", ] diff --git a/scripts/deb-postinst.sh b/scripts/deb-postinst.sh index ec6151cf..fcb7eb3e 100755 --- a/scripts/deb-postinst.sh +++ b/scripts/deb-postinst.sh @@ -60,7 +60,7 @@ seed_base_profiles() { mkdir -p "$CAPSEM_DIR/bin" "$CAPSEM_DIR/assets" "$CAPSEM_DIR/profiles/base" "$CAPSEM_DIR/run" # Symlink system binaries into user dir -for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-admin; do +for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-tui capsem-admin; do if [ -f "/usr/bin/$bin" ]; then ln -sf "/usr/bin/$bin" "$CAPSEM_DIR/bin/$bin" fi diff --git a/scripts/pkg-scripts/postinstall b/scripts/pkg-scripts/postinstall index 94344588..88396c12 100755 --- a/scripts/pkg-scripts/postinstall +++ b/scripts/pkg-scripts/postinstall @@ -100,7 +100,7 @@ chown -R "$USER" "$CAPSEM_DIR" install_app_bundle # Copy companion binaries from pkg payload -for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-admin; do +for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-tui capsem-admin; do src="$PKG_SHARE/bin/$bin" if [ -f "$src" ]; then cp "$src" "$CAPSEM_DIR/bin/$bin" diff --git a/scripts/repack-deb.sh b/scripts/repack-deb.sh index 352e37e4..db087e71 100755 --- a/scripts/repack-deb.sh +++ b/scripts/repack-deb.sh @@ -18,6 +18,7 @@ # /usr/bin/capsem-mcp-builtin # /usr/bin/capsem-gateway # /usr/bin/capsem-tray +# /usr/bin/capsem-tui # /usr/bin/capsem-admin # /usr/share/capsem/admin-python/ # /usr/share/capsem/profiles/base/*.profile.toml @@ -54,7 +55,7 @@ dpkg-deb -R "$INPUT_DEB" "$WORK_DIR/deb" echo "=== Adding companion binaries ===" mkdir -p "$WORK_DIR/deb/usr/bin" -for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-admin; do +for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-tui capsem-admin; do src="$BIN_DIR/$bin" if [ -f "$src" ]; then cp "$src" "$WORK_DIR/deb/usr/bin/$bin" diff --git a/scripts/simulate-install.sh b/scripts/simulate-install.sh index 5e8a35ea..dcfa8f52 100755 --- a/scripts/simulate-install.sh +++ b/scripts/simulate-install.sh @@ -49,7 +49,7 @@ copy_if_different() { } # Copy binaries -for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray; do +for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-tui; do src="$BIN_SRC/$bin" dst="$INSTALL_DIR/$bin" if [[ ! -f "$src" ]]; then diff --git a/scripts/verify_deb_payload.py b/scripts/verify_deb_payload.py index e7205ac0..00432ab4 100644 --- a/scripts/verify_deb_payload.py +++ b/scripts/verify_deb_payload.py @@ -26,6 +26,7 @@ "usr/bin/capsem-mcp-builtin", "usr/bin/capsem-gateway", "usr/bin/capsem-tray", + "usr/bin/capsem-tui", "usr/bin/capsem-admin", "usr/share/capsem/admin-python/capsem/admin/cli.py", "usr/share/capsem/assets/manifest.json", diff --git a/tests/capsem-install/conftest.py b/tests/capsem-install/conftest.py index 8c2e7fb5..dc750bd4 100644 --- a/tests/capsem-install/conftest.py +++ b/tests/capsem-install/conftest.py @@ -95,6 +95,7 @@ def _resolve_capsem_home() -> Path: "capsem-mcp-builtin", "capsem-gateway", "capsem-tray", + "capsem-tui", ] CAPSEM_DIR = _resolve_capsem_home() @@ -111,6 +112,7 @@ def _resolve_capsem_home() -> Path: "capsem-mcp-builtin", "capsem-gateway", "capsem-tray", + "capsem-tui", ] DEFAULT_TIMEOUT = 30 _LOCAL_BUILD_DONE = False diff --git a/tests/test_package_scripts.py b/tests/test_package_scripts.py index 850b16ae..0ab84122 100644 --- a/tests/test_package_scripts.py +++ b/tests/test_package_scripts.py @@ -30,6 +30,7 @@ "capsem-mcp-builtin", "capsem-gateway", "capsem-tray", + "capsem-tui", "capsem-admin", ] @@ -156,7 +157,7 @@ def test_build_pkg_payload_includes_signed_manifest_and_helpers(tmp_path): test -f "$root/usr/local/share/capsem/assets/manifest-sign.dev.pub" test -f "$root/usr/local/share/capsem/Capsem.app/Contents/Info.plist" test -f "$root/usr/local/share/capsem/admin-python/capsem/admin/cli.py" -for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-admin; do +for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-tui capsem-admin; do test -x "$root/usr/local/share/capsem/bin/$bin" done mkdir -p "$(dirname "$out")" @@ -272,7 +273,7 @@ def test_repack_deb_copies_signed_manifest_and_all_helpers(tmp_path): test -f "$root/usr/share/capsem/assets/manifest.json.minisig" test -f "$root/usr/share/capsem/assets/manifest-sign.dev.pub" test -f "$root/usr/share/capsem/admin-python/capsem/admin/cli.py" - for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-admin; do + for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray capsem-tui capsem-admin; do test -x "$root/usr/bin/$bin" done printf deb > "$out" diff --git a/tests/test_release_workflow_policy.py b/tests/test_release_workflow_policy.py index a6f33dbe..68f9fb18 100644 --- a/tests/test_release_workflow_policy.py +++ b/tests/test_release_workflow_policy.py @@ -238,6 +238,7 @@ def test_linux_deb_contents_validation_checks_each_required_payload(): "usr/bin/capsem-mcp-builtin", "usr/bin/capsem-gateway", "usr/bin/capsem-tray", + "usr/bin/capsem-tui", "usr/bin/capsem-admin", "usr/share/capsem/admin-python/capsem/admin/cli.py", "usr/share/capsem/assets/manifest.json", @@ -567,6 +568,7 @@ def test_local_install_verifies_fresh_install_and_guest_network(): "capsem-mcp-builtin", "capsem-gateway", "capsem-tray", + "capsem-tui", ): assert f'assert_executable "$HOME/.capsem/bin/{binary}"' in body @@ -651,6 +653,7 @@ def test_simulate_install_copies_only_arch_asset_files(tmp_path): "capsem-mcp-builtin", "capsem-gateway", "capsem-tray", + "capsem-tui", ) for binary in binaries: path = bin_src / binary diff --git a/tests/test_repack_deb.py b/tests/test_repack_deb.py index aceb5e00..369e2977 100644 --- a/tests/test_repack_deb.py +++ b/tests/test_repack_deb.py @@ -33,6 +33,7 @@ "capsem-mcp-builtin", "capsem-gateway", "capsem-tray", + "capsem-tui", "capsem-admin", ] From 33684fcd2a1d69453e440e2873d54f18a475f66b Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 17:06:26 -0400 Subject: [PATCH 07/35] fix: compile debug report disk stats on macos --- CHANGELOG.md | 2 ++ crates/capsem-service/src/debug_report.rs | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d953e09a..531ef2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cold-booting. ### Fixed +- Fixed macOS release builds of the service debug report by widening filesystem + block counts before computing disk byte totals. - Refreshed local profile asset pins during dev service startup so benchmark runs after `_pack-initrd` use matching initrd/rootfs hashes. - Expanded x86_64 KVM warm-restore groundwork by checkpointing VM interrupt diff --git a/crates/capsem-service/src/debug_report.rs b/crates/capsem-service/src/debug_report.rs index 992a0907..75a90437 100644 --- a/crates/capsem-service/src/debug_report.rs +++ b/crates/capsem-service/src/debug_report.rs @@ -775,8 +775,10 @@ fn disk_path_report(path: &Path) -> DiskPathReport { DiskPathReport { path: redact_path_for_report(path), exists, - total_bytes: Some(stat.blocks().saturating_mul(fragment_size)), - available_bytes: Some(stat.blocks_available().saturating_mul(fragment_size)), + total_bytes: Some(u64::from(stat.blocks()).saturating_mul(fragment_size)), + available_bytes: Some( + u64::from(stat.blocks_available()).saturating_mul(fragment_size), + ), error: None, } } From ad92082a894cd02b160f2e1cd9cb872a48c11918 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 17:10:20 -0400 Subject: [PATCH 08/35] fix: compile process shutdown on macos --- CHANGELOG.md | 3 +++ crates/capsem-process/src/ipc.rs | 2 +- crates/capsem-process/src/main.rs | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 531ef2ec..65ce211d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed macOS release builds of the service debug report by widening filesystem block counts before computing disk byte totals. +- Fixed macOS release builds of `capsem-process` shutdown handling by returning + the VM stop result from the main-thread stop task and avoiding a macOS-only + unused signal receiver. - Refreshed local profile asset pins during dev service startup so benchmark runs after `_pack-initrd` use matching initrd/rootfs hashes. - Expanded x86_64 KVM warm-restore groundwork by checkpointing VM interrupt diff --git a/crates/capsem-process/src/ipc.rs b/crates/capsem-process/src/ipc.rs index d6352d66..7f36726f 100644 --- a/crates/capsem-process/src/ipc.rs +++ b/crates/capsem-process/src/ipc.rs @@ -609,7 +609,7 @@ pub(crate) async fn handle_ipc_connection( { capsem_core::hypervisor::apple_vz::run_on_main_thread(move || { vm_for_stop.blocking_lock().stop() - })? + }) } #[cfg(not(target_os = "macos"))] { diff --git a/crates/capsem-process/src/main.rs b/crates/capsem-process/src/main.rs index f8a7c5ab..041e9cb7 100644 --- a/crates/capsem-process/src/main.rs +++ b/crates/capsem-process/src/main.rs @@ -296,7 +296,7 @@ fn main() -> Result<()> { // `session.db-wal`. See /dev-rust-patterns "Signal-driven explicit // cleanup for background-thread owners". let shutdown_for_sig = Arc::clone(&shutdown); - let (signal_exit_tx, signal_exit_rx) = tokio::sync::oneshot::channel::<()>(); + let (signal_exit_tx, _signal_exit_rx) = tokio::sync::oneshot::channel::<()>(); rt.spawn(async move { use tokio::signal::unix::{signal, SignalKind}; let mut sigterm = signal(SignalKind::terminate()).unwrap(); @@ -342,7 +342,7 @@ fn main() -> Result<()> { } #[cfg(not(target_os = "macos"))] { - let _ = rt.block_on(signal_exit_rx); + let _ = rt.block_on(_signal_exit_rx); } Ok(()) From b8ca85894235aba02e7a58cd7ce65821ab655c4a Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 17:17:48 -0400 Subject: [PATCH 09/35] fix: ignore manifest aliases in install profiles --- CHANGELOG.md | 2 ++ scripts/materialize-install-profiles.py | 11 +++++++++-- tests/test_package_scripts.py | 5 +++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ce211d..9615afeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed macOS release builds of `capsem-process` shutdown handling by returning the VM stop result from the main-thread stop task and avoiding a macOS-only unused signal receiver. +- Fixed install profile materialization so manifest aliases without local asset + directories do not make package assembly look for non-existent VM assets. - Refreshed local profile asset pins during dev service startup so benchmark runs after `_pack-initrd` use matching initrd/rootfs hashes. - Expanded x86_64 KVM warm-restore groundwork by checkpointing VM interrupt diff --git a/scripts/materialize-install-profiles.py b/scripts/materialize-install-profiles.py index 5a187caa..1a847dcf 100755 --- a/scripts/materialize-install-profiles.py +++ b/scripts/materialize-install-profiles.py @@ -160,17 +160,24 @@ def main() -> int: asset_release = manifest["assets"]["current"] arches = manifest["assets"]["releases"][asset_release]["arches"] + available_arches: dict[str, dict[str, dict[str, object]]] = {} for arch, arch_assets in arches.items(): + arch_dir = assets_dir / arch + if not arch_dir.is_dir(): + continue for logical_name in ("vmlinuz", "initrd.img", "rootfs.squashfs"): if logical_name not in arch_assets: raise ValueError(f"manifest missing {arch}/{logical_name}") - source_asset = assets_dir / arch / logical_name + source_asset = arch_dir / logical_name if not source_asset.is_file(): raise ValueError(f"asset file missing: {source_asset}") + available_arches[arch] = arch_assets + if not available_arches: + raise ValueError(f"manifest has no arches with local asset files under {assets_dir}") out_dir.mkdir(parents=True, exist_ok=True) for profile in sorted(profile_src_dir.glob("*.profile.toml")): - rendered = _rewrite_profile(profile, asset_release, arches, asset_source_root) + rendered = _rewrite_profile(profile, asset_release, available_arches, asset_source_root) (out_dir / profile.name).write_text(rendered, encoding="utf-8") print(f" Materialized: {profile.name}") diff --git a/tests/test_package_scripts.py b/tests/test_package_scripts.py index 0ab84122..2615c0fc 100644 --- a/tests/test_package_scripts.py +++ b/tests/test_package_scripts.py @@ -77,6 +77,11 @@ def _seed_assets(assets_dir: Path) -> None: "vmlinuz": {"hash": "1" * 64, "size": 6}, "initrd.img": {"hash": "2" * 64, "size": 6}, "rootfs.squashfs": {"hash": "3" * 64, "size": 6}, + }, + "current": { + "vmlinuz": {"hash": "1" * 64, "size": 6}, + "initrd.img": {"hash": "2" * 64, "size": 6}, + "rootfs.squashfs": {"hash": "3" * 64, "size": 6}, } }, } From 03fcce340197380eaee602495f6e667d7e3af607 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 17:24:29 -0400 Subject: [PATCH 10/35] fix: skip asset alias directories in install profiles --- CHANGELOG.md | 5 +++-- scripts/materialize-install-profiles.py | 3 +++ tests/test_package_scripts.py | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9615afeb..8a7eea8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,8 +118,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed macOS release builds of `capsem-process` shutdown handling by returning the VM stop result from the main-thread stop task and avoiding a macOS-only unused signal receiver. -- Fixed install profile materialization so manifest aliases without local asset - directories do not make package assembly look for non-existent VM assets. +- Fixed install profile materialization so manifest aliases and legacy local + alias directories do not make package assembly look for non-existent VM + assets. - Refreshed local profile asset pins during dev service startup so benchmark runs after `_pack-initrd` use matching initrd/rootfs hashes. - Expanded x86_64 KVM warm-restore groundwork by checkpointing VM interrupt diff --git a/scripts/materialize-install-profiles.py b/scripts/materialize-install-profiles.py index 1a847dcf..291a6b1d 100755 --- a/scripts/materialize-install-profiles.py +++ b/scripts/materialize-install-profiles.py @@ -36,6 +36,7 @@ "initrd": "application/octet-stream", "rootfs": "application/vnd.squashfs", } +SUPPORTED_ARCHES = {"arm64", "x86_64"} def _usage() -> str: @@ -162,6 +163,8 @@ def main() -> int: available_arches: dict[str, dict[str, dict[str, object]]] = {} for arch, arch_assets in arches.items(): + if arch not in SUPPORTED_ARCHES: + continue arch_dir = assets_dir / arch if not arch_dir.is_dir(): continue diff --git a/tests/test_package_scripts.py b/tests/test_package_scripts.py index 2615c0fc..8ddc85fa 100644 --- a/tests/test_package_scripts.py +++ b/tests/test_package_scripts.py @@ -55,6 +55,9 @@ def _seed_assets(assets_dir: Path) -> None: assets_dir.mkdir(parents=True) arch_dir = assets_dir / "arm64" arch_dir.mkdir() + alias_dir = assets_dir / "current" + alias_dir.mkdir() + (alias_dir / "vmlinuz").write_bytes(b"legacy-alias") for name, body in { "vmlinuz": b"kernel", "initrd.img": b"initrd", From c93351ee7c92f9e1bbeb290596c8e2758c70647d Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 18:15:49 -0400 Subject: [PATCH 11/35] fix: finish tui live terminal proof --- CHANGELOG.md | 3 +++ crates/capsem-tui/src/gateway_provider.rs | 2 +- crates/capsem-tui/src/main.rs | 6 +----- crates/capsem-tui/src/tests.rs | 5 +++++ sprints/tui-control/MASTER.md | 14 +++++++++---- sprints/tui-control/tracker.md | 25 +++++++++++++++-------- 6 files changed, 37 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7eea8c..b6f1ec44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cold-booting. ### Fixed +- Fixed `capsem-tui` live gateway attention handling so sessions with + `profile_status=current` are not marked stale, and proved the installed + terminal WebSocket path against two running service sessions. - Fixed macOS release builds of the service debug report by widening filesystem block counts before computing disk byte totals. - Fixed macOS release builds of `capsem-process` shutdown handling by returning diff --git a/crates/capsem-tui/src/gateway_provider.rs b/crates/capsem-tui/src/gateway_provider.rs index a0046c0f..60122d73 100644 --- a/crates/capsem-tui/src/gateway_provider.rs +++ b/crates/capsem-tui/src/gateway_provider.rs @@ -206,7 +206,7 @@ fn attention_from_vm(vm: &VmSummary, lifecycle: SessionLifecycle) -> Vec &'static str { "persistent": true, "profile_id": "profile-v2", "profile_revision": "main", + "profile_status": "current", "uptime_secs": 2840, "total_input_tokens": 30000, "total_output_tokens": 8912, diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 507efd99..9d4ed67c 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -32,7 +32,7 @@ thin client over typed state and actions exposed by Capsem service/gateway APIs. | T07 | Done | Wire installed gateway read-only state | HTTP provider test + live snapshot | | T08 | Done | Safe service control actions | confirmation/action tests | | T09 | Not Started | Remote transport readiness | reconnect/event cursor tests | -| T10 | In Progress | Active terminal WebSocket surface | terminal buffer/input tests | +| T10 | Done | Active terminal WebSocket surface | terminal buffer/input tests + live two-session gateway proof | ## Current Decision @@ -42,7 +42,10 @@ falls back to `http://127.0.0.1:19222`, fetches `/token`, and then polls authenticated `GET /status`. Safe mutating actions go through the same gateway with a confirmation overlay and a background worker. `--fixture` keeps the two-session demo path for visual iteration; `--gateway-url` turns connection -failures into explicit errors for focused gateway testing. +failures into explicit errors for focused gateway testing. The active terminal +WebSocket path is live-proven against MCP-created `tui-proof-a` and +`tui-proof-b`; healthy `profile_status=current` sessions no longer render stale +attention markers. ## T00 Closeout @@ -95,6 +98,9 @@ failures into explicit errors for focused gateway testing. - Added confirmed create/resume/suspend/stop/delete actions through the installed gateway, with background execution so long service operations do not block terminal rendering. +- Proved the installed gateway path with two live persistent sessions created + through Capsem MCP. `capsem-tui --snapshot` renders both sessions and a direct + gateway WebSocket command returned `TUI_WS_PROOF_A` from `tui-proof-a`. - Kept richer missing state explicit for future API work: waiting-for-input, terminal bell, per-session repo/path metadata, security/enforcement/detection totals, and event cursor semantics are not invented by the TUI. @@ -106,8 +112,8 @@ failures into explicit errors for focused gateway testing. - Adversarial: malformed gateway status, authenticated provider parsing, and action error propagation. - E2E/VM: live empty-service snapshot covered; live multi-VM terminal session - proof remains open because the installed service is missing the exact pinned - `initrd.img` and `assets.capsem.dev` does not resolve on this host. + proof covered with MCP-created `tui-proof-a` and `tui-proof-b`, plus installed + gateway terminal WebSocket shell output from `tui-proof-a`. - Telemetry: mapped from current counters; event stream/cursor semantics remain open. - Performance: frame/render timing deferred until interactive loop exists. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 261c805f..59dd2ab1 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -30,7 +30,7 @@ - [x] Add confirmed create/resume/suspend/stop/delete actions through the installed HTTP gateway. - [x] Run live installed-gateway empty-service snapshot. -- [ ] Run live two-session terminal proof. +- [x] Run live two-session terminal proof. - [x] Commit functional milestone. ## Notes @@ -76,15 +76,23 @@ active session, F7 stops it, and F8 deletes it. Action calls run on a background worker so long suspend/stop/provision paths do not freeze terminal rendering. -- Live VM proof is currently blocked by installed asset readiness, not by the - TUI route. `capsem_create(name=tui-proof-a)` fails because - `/Users/elie/.capsem/assets/arm64/initrd-b2be4ef1b9033569.img` is missing - and `assets.capsem.dev` does not resolve on this host while other DNS - lookups such as `github.com` do resolve. +- Live VM proof is unblocked. MCP `capsem_list` reports asset health ready and + two running persistent proof sessions, `tui-proof-a` and `tui-proof-b`, on + `everyday-work@2026.0529.5`. +- Live snapshot now renders both proof sessions without false attention markers: + `cargo run -p capsem-tui -- --snapshot --width 120 --height 30`. +- Live terminal WebSocket proof through the installed HTTP gateway succeeded + against `tui-proof-a` and returned `TUI_WS_PROOF_A` from the VM shell. +- Fixed `profile_status=current` handling so healthy profile pins do not render + stale/attention markers. +- MCP triage for `tui-proof-a` found no session-level failures. Host triage + still shows stale gateway terminal reconnect errors for the removed + `crafty-panda` socket, which are unrelated to the proof sessions. ## Coverage Ledger - Unit/contract: `cargo test -p capsem-tui` (18 tests). +- Formatting: `cargo fmt -p capsem-tui -- --check`. - Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`; `cargo run -p capsem-tui -- --fixture --snapshot --width 120 --height 30`; `cargo run -p capsem-tui -- --fixture --snapshot-svg --width 120 --height 30`; @@ -100,8 +108,9 @@ - Adversarial: malformed gateway status mapping; action error response body surfaced to the status bar instead of being swallowed. - E2E/VM: live installed-gateway empty-service snapshot works; live - multi-session terminal proof is blocked by missing installed `initrd.img` and - asset-host DNS failure. + multi-session terminal proof works with MCP-created `tui-proof-a` and + `tui-proof-b`; installed gateway terminal WebSocket returned VM shell output + from `tui-proof-a`. - Telemetry: current gateway `/status` counters mapped; event-stream semantics still open. - Performance: not measured yet. From ec0c715208e305a03249470ee201a3a30aa276c4 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 18:36:56 -0400 Subject: [PATCH 12/35] fix: use vt parser for tui terminal --- CHANGELOG.md | 3 + crates/capsem-tui/Cargo.toml | 1 + crates/capsem-tui/src/main.rs | 127 ++++++++++---- crates/capsem-tui/src/terminal.rs | 219 ++++++++++++++++-------- crates/capsem-tui/src/terminal/tests.rs | 60 ++++++- crates/capsem-tui/src/ui.rs | 51 +++++- sprints/tui-control/MASTER.md | 4 + sprints/tui-control/tracker.md | 12 +- 8 files changed, 361 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f1ec44..332493b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed `capsem-tui` live gateway attention handling so sessions with `profile_status=current` are not marked stale, and proved the installed terminal WebSocket path against two running service sessions. +- Fixed `capsem-tui` terminal rendering to use a real VT/xterm parser with + color/style preservation, adjacent output coalescing, and dirty-frame + redraws instead of a hand-rolled ANSI text flattener. - Fixed macOS release builds of the service debug report by widening filesystem block counts before computing disk byte totals. - Fixed macOS release builds of `capsem-process` shutdown handling by returning diff --git a/crates/capsem-tui/Cargo.toml b/crates/capsem-tui/Cargo.toml index eba2100e..7f9d210a 100644 --- a/crates/capsem-tui/Cargo.toml +++ b/crates/capsem-tui/Cargo.toml @@ -24,6 +24,7 @@ serde.workspace = true serde_json.workspace = true tokio.workspace = true tokio-tungstenite = "0.29.0" +vt100 = "0.16.2" [lints] workspace = true diff --git a/crates/capsem-tui/src/main.rs b/crates/capsem-tui/src/main.rs index d159094f..6eadfdea 100644 --- a/crates/capsem-tui/src/main.rs +++ b/crates/capsem-tui/src/main.rs @@ -20,6 +20,8 @@ use crossterm::terminal::{ use ratatui::backend::CrosstermBackend; use ratatui::Terminal; +const UI_TICK_INTERVAL: Duration = Duration::from_millis(16); + #[derive(Parser)] #[command(author, version, about = "Capsem terminal control UI")] struct Cli { @@ -160,10 +162,13 @@ fn run_loop( let mut last_refresh = Instant::now(); let mut surface = TerminalSurface::new(); let mut connected_session_id = String::new(); + let mut needs_draw = true; + let input_events = spawn_input_reader(); loop { if let Some(bridge) = &control_bridge { let mut should_refresh = false; for event in bridge.drain_events() { + needs_draw = true; match event { ControlEvent::Started(label) => { app.set_control_message(format!("{label}...")); @@ -179,58 +184,101 @@ fn run_loop( } } if should_refresh { - refresh_state(app, live_provider.as_ref()); + needs_draw |= refresh_state(app, live_provider.as_ref()); } } if let Some(bridge) = &terminal_bridge { - for event in bridge.drain_events() { + let events = bridge.drain_events(); + if !events.is_empty() { + needs_draw = true; + } + for event in events { surface.apply(event); } - sync_terminal_connection( + let size = terminal.size()?; + let active_id = app.state().active_session_id.clone(); + if !active_id.is_empty() { + surface.resize(&active_id, size.width, size.height.saturating_sub(1)); + } + needs_draw |= sync_terminal_connection( app, bridge, &mut connected_session_id, - terminal.size()?.width, - terminal.size()?.height.saturating_sub(1), + size.width, + size.height.saturating_sub(1), ); } if last_refresh.elapsed() >= refresh_interval { - refresh_state(app, live_provider.as_ref()); + needs_draw |= refresh_state(app, live_provider.as_ref()); last_refresh = Instant::now(); } - terminal.draw(|frame| render_app(frame, app, Some(&surface)))?; - if event::poll(Duration::from_millis(250))? { - match event::read()? { - Event::Key(key) => match app.handle_key(key) { - AppAction::Exit => break, - AppAction::Consumed => {} - AppAction::Invoke(action) => { - if let Some(bridge) = &control_bridge { - bridge.invoke(action); - } else { - app.set_control_message("fixture action ignored"); - } - } - AppAction::Forward => { - if let (Some(bridge), Some(bytes)) = - (&terminal_bridge, key_to_terminal_bytes(key)) - { - bridge.input(bytes); - } - } - }, - Event::Resize(width, height) => { - if let Some(bridge) = &terminal_bridge { - bridge.resize(width, height.saturating_sub(1)); - } + if needs_draw { + terminal.draw(|frame| render_app(frame, app, Some(&surface)))?; + needs_draw = false; + } + match input_events.recv_timeout(UI_TICK_INTERVAL) { + Ok(Ok(event)) => { + if handle_terminal_event( + event, + app, + terminal_bridge.as_ref(), + control_bridge.as_ref(), + )? { + break; } - _ => {} + needs_draw = true; } + Ok(Err(error)) => return Err(error).context("read terminal input event"), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, } } Ok(()) } +fn spawn_input_reader() -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || loop { + if tx.send(event::read()).is_err() { + break; + } + }); + rx +} + +fn handle_terminal_event( + event: Event, + app: &mut App, + terminal_bridge: Option<&TerminalBridge>, + control_bridge: Option<&ControlBridge>, +) -> Result { + match event { + Event::Key(key) => match app.handle_key(key) { + AppAction::Exit => return Ok(true), + AppAction::Consumed => {} + AppAction::Invoke(action) => { + if let Some(bridge) = control_bridge { + bridge.invoke(action); + } else { + app.set_control_message("fixture action ignored"); + } + } + AppAction::Forward => { + if let (Some(bridge), Some(bytes)) = (terminal_bridge, key_to_terminal_bytes(key)) { + bridge.input(bytes); + } + } + }, + Event::Resize(width, height) => { + if let Some(bridge) = terminal_bridge { + bridge.resize(width, height.saturating_sub(1)); + } + } + _ => {} + } + Ok(false) +} + struct ControlBridge { commands: mpsc::Sender, events: mpsc::Receiver, @@ -280,21 +328,25 @@ fn sync_terminal_connection( connected_session_id: &mut String, cols: u16, rows: u16, -) { +) -> bool { let active_id = &app.state().active_session_id; if active_id.is_empty() || active_id == connected_session_id { - return; + return false; } bridge.connect(active_id.clone(), cols, rows); connected_session_id.clone_from(active_id); + true } -fn refresh_state(app: &mut App, provider: Option<&GatewayProvider>) { +fn refresh_state(app: &mut App, provider: Option<&GatewayProvider>) -> bool { let Some(provider) = provider else { - return; + return false; }; match provider.load() { - Ok(state) => app.replace_state(state), + Ok(state) => { + app.replace_state(state); + true + } Err(_) => { let mut state = app.state().clone(); state.service.status = ServiceStatus::Offline; @@ -307,6 +359,7 @@ fn refresh_state(app: &mut App, provider: Option<&GatewayProvider>) { .saturating_add(1), ); app.replace_state(state); + true } } } diff --git a/crates/capsem-tui/src/terminal.rs b/crates/capsem-tui/src/terminal.rs index ca7448ee..2e3d1e90 100644 --- a/crates/capsem-tui/src/terminal.rs +++ b/crates/capsem-tui/src/terminal.rs @@ -52,7 +52,7 @@ impl TerminalBridge { pub fn drain_events(&self) -> Vec { let mut events = Vec::new(); while let Ok(event) = self.events.try_recv() { - events.push(event); + push_coalesced_event(&mut events, event); } events } @@ -85,6 +85,21 @@ pub enum TerminalEvent { Status { session_id: String, status: String }, } +fn push_coalesced_event(events: &mut Vec, event: TerminalEvent) { + match (events.last_mut(), event) { + ( + Some(TerminalEvent::Output { + session_id: previous_id, + bytes: previous_bytes, + }), + TerminalEvent::Output { session_id, bytes }, + ) if previous_id == &session_id => { + previous_bytes.extend_from_slice(&bytes); + } + (_, event) => events.push(event), + } +} + async fn run_terminal_manager( base_url: String, mut commands: tokio_mpsc::UnboundedReceiver, @@ -281,7 +296,6 @@ fn send_status(events: &mpsc::Sender, session_id: &str, status: i }); } -#[derive(Clone, Debug, Eq, PartialEq)] pub struct TerminalSurface { buffers: BTreeMap, } @@ -305,12 +319,23 @@ impl TerminalSurface { } pub fn lines_for(&self, session_id: &str, height: usize) -> Vec { + self.styled_lines_for(session_id, height) + .into_iter() + .map(|line| line.plain_text()) + .collect() + } + + pub fn styled_lines_for(&self, session_id: &str, height: usize) -> Vec { self.buffers .get(session_id) .map(|buffer| buffer.visible_lines(height)) .unwrap_or_default() } + pub fn resize(&mut self, session_id: &str, cols: u16, rows: u16) { + self.buffer_mut(session_id).resize(cols, rows); + } + pub fn status_for(&self, session_id: &str) -> Option<&str> { self.buffers .get(session_id) @@ -328,104 +353,152 @@ impl Default for TerminalSurface { } } -#[derive(Clone, Debug, Eq, PartialEq)] struct TerminalBuffer { - lines: Vec, - parser_state: ParserState, + parser: vt100::Parser, status: Option, } impl TerminalBuffer { fn append(&mut self, bytes: &[u8]) { - let text = String::from_utf8_lossy(bytes); - for ch in text.chars() { - self.process_char(ch); - } - self.truncate(); + self.parser.process(bytes); } - fn process_char(&mut self, ch: char) { - match self.parser_state { - ParserState::Ground => self.process_ground(ch), - ParserState::Escape => { - self.parser_state = if ch == '[' { - ParserState::Csi(String::new()) - } else { - ParserState::Ground - }; - } - ParserState::Csi(ref mut params) => { - if ('@'..='~').contains(&ch) { - let command = std::mem::take(params); - self.parser_state = ParserState::Ground; - self.apply_csi(&command, ch); - } else { - params.push(ch); - } - } - } + fn visible_lines(&self, height: usize) -> Vec { + let screen = self.parser.screen(); + let (rows, cols) = screen.size(); + let start_row = usize::from(rows).saturating_sub(height); + (start_row..usize::from(rows)) + .map(|row| line_from_screen_row(screen, row as u16, cols)) + .collect() } - fn process_ground(&mut self, ch: char) { - match ch { - '\u{1b}' => self.parser_state = ParserState::Escape, - '\r' => {} - '\n' => self.lines.push(String::new()), - '\u{8}' | '\u{7f}' => { - let _ = self.current_line().pop(); - } - '\t' => self.current_line().push_str(" "), - ch if !ch.is_control() => self.current_line().push(ch), - _ => {} - } + fn resize(&mut self, cols: u16, rows: u16) { + self.parser.screen_mut().set_size(rows.max(1), cols.max(1)); } +} - fn apply_csi(&mut self, params: &str, command: char) { - match command { - 'J' if params.ends_with('2') || params.is_empty() => { - self.lines.clear(); - self.lines.push(String::new()); - } - 'K' => self.current_line().clear(), - _ => {} +impl Default for TerminalBuffer { + fn default() -> Self { + Self { + parser: vt100::Parser::new(24, 80, MAX_SCROLLBACK_LINES), + status: None, } } +} - fn current_line(&mut self) -> &mut String { - if self.lines.is_empty() { - self.lines.push(String::new()); - } - self.lines.last_mut().expect("line exists") +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TerminalLine { + spans: Vec, +} + +impl TerminalLine { + pub fn spans(&self) -> &[TerminalSpan] { + &self.spans + } + + pub fn plain_text(&self) -> String { + self.spans + .iter() + .map(|span| span.text.as_str()) + .collect::() + .trim_end() + .to_string() } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TerminalSpan { + pub text: String, + pub style: TerminalStyle, +} - fn visible_lines(&self, height: usize) -> Vec { - let start = self.lines.len().saturating_sub(height); - self.lines[start..].to_vec() +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TerminalStyle { + pub fg: TerminalColor, + pub bg: TerminalColor, + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub underline: bool, + pub inverse: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerminalColor { + Default, + Indexed(u8), + Rgb(u8, u8, u8), +} + +impl Default for TerminalColor { + fn default() -> Self { + Self::Default } +} - fn truncate(&mut self) { - let overflow = self.lines.len().saturating_sub(MAX_SCROLLBACK_LINES); - if overflow > 0 { - self.lines.drain(..overflow); +fn line_from_screen_row(screen: &vt100::Screen, row: u16, cols: u16) -> TerminalLine { + let mut line = TerminalLine::default(); + for col in 0..cols { + let Some(cell) = screen.cell(row, col) else { + continue; + }; + if cell.is_wide_continuation() { + continue; } + let text = if cell.has_contents() { + cell.contents() + } else { + " " + }; + push_screen_text(&mut line, text, style_from_cell(cell)); } + trim_terminal_line(&mut line); + line } -impl Default for TerminalBuffer { - fn default() -> Self { - Self { - lines: vec![String::new()], - parser_state: ParserState::Ground, - status: None, +fn push_screen_text(line: &mut TerminalLine, text: &str, style: TerminalStyle) { + if let Some(span) = line.spans.last_mut().filter(|span| span.style == style) { + span.text.push_str(text); + return; + } + line.spans.push(TerminalSpan { + text: text.to_string(), + style, + }); +} + +fn trim_terminal_line(line: &mut TerminalLine) { + while let Some(span) = line.spans.last_mut() { + let trimmed = span.text.trim_end_matches(' '); + if trimmed.len() == span.text.len() { + break; } + span.text.truncate(trimmed.len()); + if !span.text.is_empty() { + break; + } + line.spans.pop(); } } -#[derive(Clone, Debug, Eq, PartialEq)] -enum ParserState { - Ground, - Escape, - Csi(String), +fn style_from_cell(cell: &vt100::Cell) -> TerminalStyle { + TerminalStyle { + fg: color_from_vt100(cell.fgcolor()), + bg: color_from_vt100(cell.bgcolor()), + bold: cell.bold(), + dim: cell.dim(), + italic: cell.italic(), + underline: cell.underline(), + inverse: cell.inverse(), + } +} + +fn color_from_vt100(color: vt100::Color) -> TerminalColor { + match color { + vt100::Color::Default => TerminalColor::Default, + vt100::Color::Idx(index) => TerminalColor::Indexed(index), + vt100::Color::Rgb(red, green, blue) => TerminalColor::Rgb(red, green, blue), + } } pub fn key_to_terminal_bytes(key: KeyEvent) -> Option> { diff --git a/crates/capsem-tui/src/terminal/tests.rs b/crates/capsem-tui/src/terminal/tests.rs index b86a6783..c37e6e35 100644 --- a/crates/capsem-tui/src/terminal/tests.rs +++ b/crates/capsem-tui/src/terminal/tests.rs @@ -1,10 +1,13 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use super::{key_to_terminal_bytes, TerminalEvent, TerminalSurface}; +use super::{ + key_to_terminal_bytes, push_coalesced_event, TerminalColor, TerminalEvent, TerminalSurface, +}; #[test] fn terminal_surface_keeps_recent_plain_output() { let mut surface = TerminalSurface::new(); + surface.resize("vm-1", 80, 2); surface.apply(TerminalEvent::Output { session_id: "vm-1".into(), bytes: b"hello\r\nworld".to_vec(), @@ -16,12 +19,65 @@ fn terminal_surface_keeps_recent_plain_output() { #[test] fn terminal_surface_strips_basic_ansi_sequences() { let mut surface = TerminalSurface::new(); + surface.resize("vm-1", 80, 3); surface.apply(TerminalEvent::Output { session_id: "vm-1".into(), bytes: b"\x1b[31mred\x1b[0m\n\x1b[2Jfresh".to_vec(), }); - assert_eq!(surface.lines_for("vm-1", 3), vec!["fresh"]); + assert!( + surface + .lines_for("vm-1", 3) + .iter() + .any(|line| line.contains("fresh")), + "clear-screen output should leave fresh text on the parsed screen" + ); +} + +#[test] +fn terminal_surface_preserves_xterm_colors() { + let mut surface = TerminalSurface::new(); + surface.resize("vm-1", 80, 3); + surface.apply(TerminalEvent::Output { + session_id: "vm-1".into(), + bytes: b"\x1b[31mred\x1b[0m plain \x1b[1;32mgreen\x1b[0m".to_vec(), + }); + + let lines = surface.styled_lines_for("vm-1", 3); + let spans = lines[0].spans(); + assert_eq!(spans[0].text, "red"); + assert_eq!(spans[0].style.fg, TerminalColor::Indexed(1)); + assert_eq!(spans[1].text, " plain "); + assert_eq!(spans[2].text, "green"); + assert_eq!(spans[2].style.fg, TerminalColor::Indexed(2)); + assert!(spans[2].style.bold); +} + +#[test] +fn terminal_events_coalesce_adjacent_output() { + let mut events = Vec::new(); + push_coalesced_event( + &mut events, + TerminalEvent::Output { + session_id: "vm-1".into(), + bytes: b"hel".to_vec(), + }, + ); + push_coalesced_event( + &mut events, + TerminalEvent::Output { + session_id: "vm-1".into(), + bytes: b"lo".to_vec(), + }, + ); + + assert_eq!( + events, + vec![TerminalEvent::Output { + session_id: "vm-1".into(), + bytes: b"hello".to_vec() + }] + ); } #[test] diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index b8912c12..549538c8 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -9,7 +9,7 @@ use ratatui::{Frame, Terminal}; use crate::app::{App, AppOverlay, ControlAction}; use crate::model::{AppState, ServiceStatus, SessionSummary}; -use crate::terminal::TerminalSurface; +use crate::terminal::{TerminalColor, TerminalLine, TerminalStyle, TerminalSurface}; const MAX_VISIBLE_TABS: usize = 4; const PREVIEW_BG: Color = Color::Rgb(17, 18, 29); @@ -159,9 +159,9 @@ fn render_terminal_surface( }; let active_id = state.active_session_id.as_str(); let mut lines = terminal - .lines_for(active_id, area.height as usize) + .styled_lines_for(active_id, area.height as usize) .into_iter() - .map(|line| Line::from(Span::styled(line, Style::default().fg(TEXT)))) + .map(terminal_line_to_ratatui) .collect::>(); if lines.is_empty() { let status = terminal @@ -175,6 +175,51 @@ fn render_terminal_surface( frame.render_widget(Paragraph::new(lines), area); } +fn terminal_line_to_ratatui(line: TerminalLine) -> Line<'static> { + let spans = line + .spans() + .iter() + .map(|span| Span::styled(span.text.clone(), terminal_style_to_ratatui(span.style))) + .collect::>(); + Line::from(spans) +} + +fn terminal_style_to_ratatui(style: TerminalStyle) -> Style { + let mut result = Style::default(); + let (fg, bg) = if style.inverse { + (style.bg, style.fg) + } else { + (style.fg, style.bg) + }; + if let Some(fg) = terminal_color_to_ratatui(fg) { + result = result.fg(fg); + } + if let Some(bg) = terminal_color_to_ratatui(bg) { + result = result.bg(bg); + } + if style.bold { + result = result.add_modifier(Modifier::BOLD); + } + if style.dim { + result = result.add_modifier(Modifier::DIM); + } + if style.italic { + result = result.add_modifier(Modifier::ITALIC); + } + if style.underline { + result = result.add_modifier(Modifier::UNDERLINED); + } + result +} + +fn terminal_color_to_ratatui(color: TerminalColor) -> Option { + match color { + TerminalColor::Default => None, + TerminalColor::Indexed(index) => Some(Color::Indexed(index)), + TerminalColor::Rgb(red, green, blue) => Some(Color::Rgb(red, green, blue)), + } +} + fn render_overlay( frame: &mut Frame<'_>, area: Rect, diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 9d4ed67c..cfab0fa9 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -101,6 +101,10 @@ attention markers. - Proved the installed gateway path with two live persistent sessions created through Capsem MCP. `capsem-tui --snapshot` renders both sessions and a direct gateway WebSocket command returned `TUI_WS_PROOF_A` from `tui-proof-a`. +- Replaced the temporary terminal text parser with `vt100`, preserving xterm + screen state, SGR colors, and text attributes. Client-side adjacent output + coalescing and dirty-frame redraws now mirror the existing `capsem shell` + speed contract instead of repainting on every loop. - Kept richer missing state explicit for future API work: waiting-for-input, terminal bell, per-session repo/path metadata, security/enforcement/detection totals, and event cursor semantics are not invented by the TUI. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 59dd2ab1..0382e260 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -26,6 +26,9 @@ - [x] Preserve plain `q` and Ctrl-C for the agent/terminal stream. - [x] Render active terminal output in the main Ratatui surface. - [x] Add terminal buffer, ANSI cleanup, and key encoding tests. +- [x] Replace the hand-rolled ANSI text flattener with a VT/xterm parser that + preserves terminal colors and text attributes. +- [x] Add client-side terminal output coalescing and dirty-frame redraws. - [x] Add `capsem_terminal_snapshot` MCP tool for session terminal inspection. - [x] Add confirmed create/resume/suspend/stop/delete actions through the installed HTTP gateway. @@ -85,6 +88,12 @@ against `tui-proof-a` and returned `TUI_WS_PROOF_A` from the VM shell. - Fixed `profile_status=current` handling so healthy profile pins do not render stale/attention markers. +- Terminal rendering now uses `vt100` for screen state and SGR styles. The TUI + no longer keeps a parallel ANSI parser, coalesces adjacent terminal output + events before parsing, and draws only when state/input/output marks the frame + dirty. +- Keyboard input is read by a blocking input reader thread instead of + `crossterm::event::poll`; the WebSocket path remains async and event-driven. - MCP triage for `tui-proof-a` found no session-level failures. Host triage still shows stale gateway terminal reconnect errors for the removed `crafty-panda` socket, which are unrelated to the proof sessions. @@ -101,7 +110,8 @@ plus live local snapshot through the installed gateway. - Service actions: confirmed action key tests plus authenticated mock gateway tests for successful stop and surfaced service error bodies. -- Terminal wiring: `TerminalSurface` output/ANSI tests and key-encoding tests. +- Terminal wiring: `TerminalSurface` output, xterm color/style preservation, + adjacent output coalescing, and key-encoding tests. - MCP wiring: `capsem_terminal_snapshot` router registration and rendering tests. - Overlay wiring: function-key state tests and stats overlay render test. From f54d94a0f5d53a67c2b89819f8c0a50a6797f668 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 18:41:25 -0400 Subject: [PATCH 13/35] fix: stabilize tui session navigation --- CHANGELOG.md | 5 +++ crates/capsem-tui/src/app.rs | 64 +++++++++++++++++++++++++++++++--- crates/capsem-tui/src/tests.rs | 36 ++++++++++++++++--- crates/capsem-tui/src/ui.rs | 7 ++-- sprints/tui-control/tracker.md | 8 +++++ 5 files changed, 108 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 332493b5..867e4cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed `capsem-tui` terminal rendering to use a real VT/xterm parser with color/style preservation, adjacent output coalescing, and dirty-frame redraws instead of a hand-rolled ANSI text flattener. +- Fixed `capsem-tui` service latency rendering to reserve four digits so the + bottom status bar does not shift as latency changes. +- Fixed `capsem-tui` session navigation to use app-owned Alt shortcuts and a + `Ctrl-b` prefix fallback instead of relying on terminal-dependent Cmd/Ctrl + arrow forwarding. - Fixed macOS release builds of the service debug report by widening filesystem block counts before computing disk byte totals. - Fixed macOS release builds of `capsem-process` shutdown handling by returning diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index eb6aecd8..4c225ccb 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -57,6 +57,7 @@ pub struct App { active_index: usize, overlay: AppOverlay, pending_action: Option, + prefix_pending: bool, } impl App { @@ -71,6 +72,7 @@ impl App { active_index, overlay: AppOverlay::None, pending_action: None, + prefix_pending: false, } } @@ -86,6 +88,10 @@ impl App { self.pending_action.as_ref() } + pub fn prefix_pending(&self) -> bool { + self.prefix_pending + } + pub fn replace_state(&mut self, mut state: AppState) { state.service.control_message = self.state.service.control_message.clone(); let previous_active_id = self.state.active_session_id.clone(); @@ -116,6 +122,16 @@ impl App { if let Some(action) = self.handle_pending_action_key(key) { return action; } + if self.prefix_pending { + self.prefix_pending = false; + return self.handle_prefix_key(key); + } + if is_prefix_key(key) { + self.prefix_pending = true; + self.pending_action = None; + self.overlay = AppOverlay::None; + return AppAction::Consumed; + } if self.handle_overlay_key(key) { return AppAction::Consumed; } @@ -139,6 +155,29 @@ impl App { AppAction::Forward } + fn handle_prefix_key(&mut self, key: KeyEvent) -> AppAction { + match key.code { + KeyCode::Char('h') | KeyCode::Char('p') | KeyCode::Left => { + self.previous_session(); + AppAction::Consumed + } + KeyCode::Char('l') | KeyCode::Char('n') | KeyCode::Right => { + self.next_session(); + AppAction::Consumed + } + KeyCode::Char(ch) if ch.is_ascii_digit() => { + let index = ch.to_digit(10).unwrap_or_default(); + if index > 0 { + self.select_session(index as usize - 1); + } + AppAction::Consumed + } + KeyCode::Esc => AppAction::Consumed, + _ if is_prefix_key(key) => AppAction::Forward, + _ => AppAction::Consumed, + } + } + pub fn next_session(&mut self) { if self.state.sessions.is_empty() { return; @@ -258,20 +297,35 @@ fn is_exit_key(key: KeyEvent) -> bool { ) } +fn is_prefix_key(key: KeyEvent) -> bool { + matches!( + (key.code, key.modifiers), + (KeyCode::Char('b'), KeyModifiers::CONTROL) + ) +} + fn is_previous_key(key: KeyEvent) -> bool { - is_control_key(key.modifiers) && matches!(key.code, KeyCode::Left) + is_alt_key(key.modifiers) + && matches!( + key.code, + KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('p') + ) } fn is_next_key(key: KeyEvent) -> bool { - is_control_key(key.modifiers) && matches!(key.code, KeyCode::Right) + is_alt_key(key.modifiers) + && matches!( + key.code, + KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('n') + ) } -fn is_control_key(modifiers: KeyModifiers) -> bool { - modifiers.intersects(KeyModifiers::SUPER | KeyModifiers::CONTROL | KeyModifiers::ALT) +fn is_alt_key(modifiers: KeyModifiers) -> bool { + modifiers.contains(KeyModifiers::ALT) } fn select_index(key: KeyEvent) -> Option { - if !is_control_key(key.modifiers) { + if !is_alt_key(key.modifiers) { return None; } let KeyCode::Char(value) = key.code else { diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index d8875b9e..695ae2fb 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -27,7 +27,7 @@ fn fixture_models_global_service_state_and_session_indicators() { fn snapshot_contains_light_bar_tabs_and_active_desktop() { let snapshot = render_snapshot(&fixture_state(), 100, 24).expect("render snapshot"); - assert!(snapshot.contains("● 18ms")); + assert!(snapshot.contains("● 18ms")); assert!(snapshot.contains("1 profile-v2")); assert!(snapshot.contains("2 linux-os!")); assert!(snapshot.contains("◷ 47m | # 38.4k | $ 0.21")); @@ -56,19 +56,19 @@ fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { assert_eq!(app.state().active_session_id, "profile-v2"); assert_eq!( - app.handle_key(key(KeyCode::Right, KeyModifiers::CONTROL)), + app.handle_key(key(KeyCode::Char('l'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!(app.state().active_session_id, "linux-os"); assert_eq!( - app.handle_key(key(KeyCode::Left, KeyModifiers::CONTROL)), + app.handle_key(key(KeyCode::Char('h'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!(app.state().active_session_id, "profile-v2"); assert_eq!( - app.handle_key(key(KeyCode::Char('2'), KeyModifiers::CONTROL)), + app.handle_key(key(KeyCode::Char('2'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!(app.state().active_session_id, "linux-os"); @@ -88,6 +88,34 @@ fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { ); } +#[test] +fn prefix_navigation_is_deterministic_without_mac_modifier_arrows() { + let mut app = App::new(fixture_state()); + + assert_eq!( + app.handle_key(key(KeyCode::Char('b'), KeyModifiers::CONTROL)), + AppAction::Consumed + ); + assert!(app.prefix_pending()); + + assert_eq!( + app.handle_key(key(KeyCode::Char('1'), KeyModifiers::NONE)), + AppAction::Consumed + ); + assert_eq!(app.state().active_session_id, "profile-v2"); + assert!(!app.prefix_pending()); + + assert_eq!( + app.handle_key(key(KeyCode::Char('b'), KeyModifiers::CONTROL)), + AppAction::Consumed + ); + assert_eq!( + app.handle_key(key(KeyCode::Char('l'), KeyModifiers::NONE)), + AppAction::Consumed + ); + assert_eq!(app.state().active_session_id, "linux-os"); +} + #[test] fn refresh_preserves_active_session_when_it_still_exists() { let mut app = App::new(fixture_state()); diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 549538c8..1e425fae 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -99,7 +99,7 @@ fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { service_dot(service.status), service_style(service.status, service.latency.as_millis()), ), - Span::styled(format!(" {}ms ", service.latency.as_millis()), base), + Span::styled(format!(" {:>4}ms ", service.latency.as_millis()), base), ]; if let Some(attempt) = service.reconnect_attempt { left.push(Span::styled(format!(" reconnect {attempt}"), muted_style())); @@ -275,8 +275,9 @@ fn help_lines() -> Vec> { overlay_title("keys"), overlay_line("F1 help F2 stats F3 sessions"), overlay_line("F4 new F5 resume F6 suspend F7 stop F8 delete"), - overlay_line("Cmd/Ctrl/Alt arrows switch sessions"), - overlay_line("Cmd/Ctrl/Alt number jumps to a session"), + overlay_line("Alt+h/l or Alt+arrows switch sessions"), + overlay_line("Alt+number jumps to a session"), + overlay_line("Ctrl-b then h/l/number is the fallback prefix"), overlay_line("F10 exits; q and Ctrl-C pass through"), ] } diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 0382e260..9b83a757 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -29,6 +29,9 @@ - [x] Replace the hand-rolled ANSI text flattener with a VT/xterm parser that preserves terminal colors and text attributes. - [x] Add client-side terminal output coalescing and dirty-frame redraws. +- [x] Stabilize service latency width in the bottom bar. +- [x] Replace terminal-dependent Cmd/Ctrl navigation guesses with app-owned Alt + navigation plus a `Ctrl-b` prefix fallback. - [x] Add `capsem_terminal_snapshot` MCP tool for session terminal inspection. - [x] Add confirmed create/resume/suspend/stop/delete actions through the installed HTTP gateway. @@ -94,6 +97,11 @@ dirty. - Keyboard input is read by a blocking input reader thread instead of `crossterm::event::poll`; the WebSocket path remains async and event-driven. +- Service latency reserves four digits before `ms`, preventing the center tab + strip from shifting when latency changes between one and four digits. +- Navigation is now app-owned: `Alt+h/l`, `Alt+Left/Right`, and `Alt+1..9` + switch sessions; `Ctrl-b` then `h/l/1..9` is a fallback prefix path when a + host environment eats Alt chords. - MCP triage for `tui-proof-a` found no session-level failures. Host triage still shows stale gateway terminal reconnect errors for the removed `crafty-panda` socket, which are unrelated to the proof sessions. From 91a9cf93a697f25a808d0ff5983e0f5971fead9e Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 18:45:23 -0400 Subject: [PATCH 14/35] fix: make tui shell controls alt-only --- CHANGELOG.md | 8 ++-- crates/capsem-tui/src/app.rs | 85 +++++++--------------------------- crates/capsem-tui/src/tests.rs | 52 ++++++++++----------- crates/capsem-tui/src/ui.rs | 12 ++--- justfile | 5 +- sprints/tui-control/MASTER.md | 6 +-- sprints/tui-control/tracker.md | 24 +++++----- 7 files changed, 71 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 867e4cbd..513fb8ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added the initial `capsem-tui` crate with a fixture-backed standalone terminal control screen, global service light-bar state, per-session desktop indicators, and deterministic snapshot rendering for early UI proof. -- Added a `just dev-tui` standalone TUI prototype with two fixture sessions, +- Added a `just dev-tui` standalone TUI shell with two fixture sessions, SVG snapshot export, and keyboard session switching that does not capture plain `q`. - Added live `capsem-tui` gateway wiring against the installed Capsem HTTP @@ -121,9 +121,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 redraws instead of a hand-rolled ANSI text flattener. - Fixed `capsem-tui` service latency rendering to reserve four digits so the bottom status bar does not shift as latency changes. -- Fixed `capsem-tui` session navigation to use app-owned Alt shortcuts and a - `Ctrl-b` prefix fallback instead of relying on terminal-dependent Cmd/Ctrl - arrow forwarding. +- Fixed `capsem-tui` shell controls to use an app-owned Alt namespace: + `Alt+Left/Right`, `Alt+1..9`, `Alt+n/r/s/t/d`, and `Alt+q`, instead of + terminal-dependent Cmd/Ctrl forwarding or prefix fallbacks. - Fixed macOS release builds of the service debug report by widening filesystem block counts before computing disk byte totals. - Fixed macOS release builds of `capsem-process` shutdown handling by returning diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index 4c225ccb..86981f0b 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -57,7 +57,6 @@ pub struct App { active_index: usize, overlay: AppOverlay, pending_action: Option, - prefix_pending: bool, } impl App { @@ -72,7 +71,6 @@ impl App { active_index, overlay: AppOverlay::None, pending_action: None, - prefix_pending: false, } } @@ -88,10 +86,6 @@ impl App { self.pending_action.as_ref() } - pub fn prefix_pending(&self) -> bool { - self.prefix_pending - } - pub fn replace_state(&mut self, mut state: AppState) { state.service.control_message = self.state.service.control_message.clone(); let previous_active_id = self.state.active_session_id.clone(); @@ -122,16 +116,6 @@ impl App { if let Some(action) = self.handle_pending_action_key(key) { return action; } - if self.prefix_pending { - self.prefix_pending = false; - return self.handle_prefix_key(key); - } - if is_prefix_key(key) { - self.prefix_pending = true; - self.pending_action = None; - self.overlay = AppOverlay::None; - return AppAction::Consumed; - } if self.handle_overlay_key(key) { return AppAction::Consumed; } @@ -155,29 +139,6 @@ impl App { AppAction::Forward } - fn handle_prefix_key(&mut self, key: KeyEvent) -> AppAction { - match key.code { - KeyCode::Char('h') | KeyCode::Char('p') | KeyCode::Left => { - self.previous_session(); - AppAction::Consumed - } - KeyCode::Char('l') | KeyCode::Char('n') | KeyCode::Right => { - self.next_session(); - AppAction::Consumed - } - KeyCode::Char(ch) if ch.is_ascii_digit() => { - let index = ch.to_digit(10).unwrap_or_default(); - if index > 0 { - self.select_session(index as usize - 1); - } - AppAction::Consumed - } - KeyCode::Esc => AppAction::Consumed, - _ if is_prefix_key(key) => AppAction::Forward, - _ => AppAction::Consumed, - } - } - pub fn next_session(&mut self) { if self.state.sessions.is_empty() { return; @@ -214,10 +175,13 @@ impl App { } fn handle_overlay_key(&mut self, key: KeyEvent) -> bool { + if !is_alt_key(key.modifiers) { + return false; + } let next = match key.code { - KeyCode::F(1) => AppOverlay::Help, - KeyCode::F(2) => AppOverlay::Stats, - KeyCode::F(3) => AppOverlay::Home, + KeyCode::Char('?') => AppOverlay::Help, + KeyCode::Char('i' | 'I') => AppOverlay::Stats, + KeyCode::Char('o' | 'O') => AppOverlay::Home, _ => return false, }; self.overlay = if self.overlay == next { @@ -247,12 +211,15 @@ impl App { } fn control_action_for_key(&self, key: KeyEvent) -> Option { + if !is_alt_key(key.modifiers) { + return None; + } match key.code { - KeyCode::F(4) => Some(ControlAction::CreateEphemeral), - KeyCode::F(5) => self.active_resume_action(), - KeyCode::F(6) => self.active_suspend_action(), - KeyCode::F(7) => self.active_id().map(|id| ControlAction::Stop { id }), - KeyCode::F(8) => self.active_id().map(|id| ControlAction::Delete { id }), + KeyCode::Char('n' | 'N') => Some(ControlAction::CreateEphemeral), + KeyCode::Char('r' | 'R') => self.active_resume_action(), + KeyCode::Char('s' | 'S') => self.active_suspend_action(), + KeyCode::Char('t' | 'T') => self.active_id().map(|id| ControlAction::Stop { id }), + KeyCode::Char('d' | 'D') => self.active_id().map(|id| ControlAction::Delete { id }), _ => None, } } @@ -288,36 +255,18 @@ impl App { } fn is_exit_key(key: KeyEvent) -> bool { - let modifiers = key.modifiers; - matches!( - (key.code, modifiers), - (KeyCode::Char('q'), KeyModifiers::SUPER) - | (KeyCode::Esc, KeyModifiers::CONTROL) - | (KeyCode::F(10), KeyModifiers::NONE) - ) -} - -fn is_prefix_key(key: KeyEvent) -> bool { matches!( (key.code, key.modifiers), - (KeyCode::Char('b'), KeyModifiers::CONTROL) + (KeyCode::Char('q' | 'Q'), modifiers) if is_alt_key(modifiers) ) } fn is_previous_key(key: KeyEvent) -> bool { - is_alt_key(key.modifiers) - && matches!( - key.code, - KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('p') - ) + is_alt_key(key.modifiers) && matches!(key.code, KeyCode::Left) } fn is_next_key(key: KeyEvent) -> bool { - is_alt_key(key.modifiers) - && matches!( - key.code, - KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('n') - ) + is_alt_key(key.modifiers) && matches!(key.code, KeyCode::Right) } fn is_alt_key(modifiers: KeyModifiers) -> bool { diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 695ae2fb..996e978f 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -56,13 +56,13 @@ fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { assert_eq!(app.state().active_session_id, "profile-v2"); assert_eq!( - app.handle_key(key(KeyCode::Char('l'), KeyModifiers::ALT)), + app.handle_key(key(KeyCode::Right, KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!(app.state().active_session_id, "linux-os"); assert_eq!( - app.handle_key(key(KeyCode::Char('h'), KeyModifiers::ALT)), + app.handle_key(key(KeyCode::Left, KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!(app.state().active_session_id, "profile-v2"); @@ -79,41 +79,37 @@ fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { ); assert_eq!( - app.handle_key(key(KeyCode::Esc, KeyModifiers::CONTROL)), - AppAction::Exit - ); - assert_eq!( - app.handle_key(key(KeyCode::F(10), KeyModifiers::NONE)), + app.handle_key(key(KeyCode::Char('q'), KeyModifiers::ALT)), AppAction::Exit ); } #[test] -fn prefix_navigation_is_deterministic_without_mac_modifier_arrows() { +fn shell_commands_are_alt_owned() { let mut app = App::new(fixture_state()); assert_eq!( - app.handle_key(key(KeyCode::Char('b'), KeyModifiers::CONTROL)), + app.handle_key(key(KeyCode::Char('n'), KeyModifiers::ALT)), AppAction::Consumed ); - assert!(app.prefix_pending()); + assert_eq!(app.overlay(), AppOverlay::Confirm); + assert_eq!(app.pending_action(), Some(&ControlAction::CreateEphemeral)); assert_eq!( - app.handle_key(key(KeyCode::Char('1'), KeyModifiers::NONE)), + app.handle_key(key(KeyCode::Esc, KeyModifiers::NONE)), AppAction::Consumed ); - assert_eq!(app.state().active_session_id, "profile-v2"); - assert!(!app.prefix_pending()); assert_eq!( - app.handle_key(key(KeyCode::Char('b'), KeyModifiers::CONTROL)), + app.handle_key(key(KeyCode::Char('t'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!( - app.handle_key(key(KeyCode::Char('l'), KeyModifiers::NONE)), - AppAction::Consumed + app.pending_action(), + Some(&ControlAction::Stop { + id: "profile-v2".to_string() + }) ); - assert_eq!(app.state().active_session_id, "linux-os"); } #[test] @@ -142,17 +138,17 @@ fn function_keys_toggle_hidden_overlays() { assert_eq!(app.overlay(), AppOverlay::None); assert_eq!( - app.handle_key(key(KeyCode::F(1), KeyModifiers::NONE)), + app.handle_key(key(KeyCode::Char('?'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!(app.overlay(), AppOverlay::Help); assert_eq!( - app.handle_key(key(KeyCode::F(2), KeyModifiers::NONE)), + app.handle_key(key(KeyCode::Char('i'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!(app.overlay(), AppOverlay::Stats); assert_eq!( - app.handle_key(key(KeyCode::F(2), KeyModifiers::NONE)), + app.handle_key(key(KeyCode::Char('i'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!(app.overlay(), AppOverlay::None); @@ -163,7 +159,7 @@ fn control_keys_require_confirmation_before_invoking_service_actions() { let mut app = App::new(fixture_state()); assert_eq!( - app.handle_key(key(KeyCode::F(7), KeyModifiers::NONE)), + app.handle_key(key(KeyCode::Char('t'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!(app.overlay(), AppOverlay::Confirm); @@ -195,9 +191,9 @@ fn resume_action_is_only_available_for_stopped_or_suspended_sessions() { let mut app = App::new(fixture_state()); assert_eq!( - app.handle_key(key(KeyCode::F(5), KeyModifiers::NONE)), + app.handle_key(key(KeyCode::Char('r'), KeyModifiers::ALT)), AppAction::Forward, - "running active session should not map F5 to resume" + "running active session should not map Alt+r to resume" ); let mut state = fixture_state(); @@ -206,7 +202,7 @@ fn resume_action_is_only_available_for_stopped_or_suspended_sessions() { app = App::new(state); assert_eq!( - app.handle_key(key(KeyCode::F(5), KeyModifiers::NONE)), + app.handle_key(key(KeyCode::Char('r'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!( @@ -221,7 +217,7 @@ fn resume_action_is_only_available_for_stopped_or_suspended_sessions() { fn suspend_action_requires_persistent_running_session() { let mut app = App::new(fixture_state()); assert_eq!( - app.handle_key(key(KeyCode::F(6), KeyModifiers::NONE)), + app.handle_key(key(KeyCode::Char('s'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!( @@ -235,7 +231,7 @@ fn suspend_action_requires_persistent_running_session() { state.sessions[0].persistent = false; app = App::new(state); assert_eq!( - app.handle_key(key(KeyCode::F(6), KeyModifiers::NONE)), + app.handle_key(key(KeyCode::Char('s'), KeyModifiers::ALT)), AppAction::Forward, "ephemeral sessions cannot be suspended through the service" ); @@ -244,7 +240,7 @@ fn suspend_action_requires_persistent_running_session() { #[test] fn stats_overlay_renders_on_demand_without_persistent_help() { let mut app = App::new(fixture_state()); - app.handle_key(key(KeyCode::F(2), KeyModifiers::NONE)); + app.handle_key(key(KeyCode::Char('i'), KeyModifiers::ALT)); let snapshot = render_app_snapshot(&app, 100, 24).expect("render app snapshot"); @@ -254,7 +250,7 @@ fn stats_overlay_renders_on_demand_without_persistent_help() { assert!( !render_snapshot(&fixture_state(), 100, 24) .expect("render base snapshot") - .contains("F1 help"), + .contains("Alt+?"), "help is hidden until requested" ); } diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 1e425fae..3533f7cb 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -273,12 +273,12 @@ fn overlay_height(state: &AppState, overlay: AppOverlay) -> u16 { fn help_lines() -> Vec> { vec![ overlay_title("keys"), - overlay_line("F1 help F2 stats F3 sessions"), - overlay_line("F4 new F5 resume F6 suspend F7 stop F8 delete"), - overlay_line("Alt+h/l or Alt+arrows switch sessions"), - overlay_line("Alt+number jumps to a session"), - overlay_line("Ctrl-b then h/l/number is the fallback prefix"), - overlay_line("F10 exits; q and Ctrl-C pass through"), + overlay_line("Alt+Left/Right switch sessions"), + overlay_line("Alt+1..9 jumps to a session"), + overlay_line("Alt+n new Alt+r resume Alt+s suspend"), + overlay_line("Alt+t stop Alt+d delete Alt+q quit"), + overlay_line("Alt+? help Alt+i stats Alt+o sessions"), + overlay_line("plain q, Ctrl-C, and shell keys pass through"), ] } diff --git a/justfile b/justfile index 5509aeb4..69aa8f63 100644 --- a/justfile +++ b/justfile @@ -255,7 +255,10 @@ ui: _ensure-setup _pnpm-install run-service dev-frontend: _pnpm-install cd frontend && pnpm run dev -# Standalone terminal control-plane prototype. +# Standalone terminal control-plane shell. +# App-owned controls: Alt+Left/Right switch sessions; Alt+1..9 jumps; +# Alt+n new, Alt+r resume, Alt+s suspend, Alt+t stop, Alt+d delete, Alt+q quit; +# Alt+? help, Alt+i stats, Alt+o sessions. Plain q/Ctrl-C pass to the VM. # Pass extra args after `--`: `just dev-tui -- --snapshot`. dev-tui *ARGS: cargo run -p capsem-tui {{ARGS}} diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index cfab0fa9..685ffb39 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -62,9 +62,9 @@ attention markers. service state on the left, numbered session tabs in the center, and active session stats on the right. - Added a typed app controller for session switching. -- Kept plain `q` and Ctrl-C available for the agent/terminal stream; standalone - exits on F10, Ctrl-Esc, or Cmd-Q when the terminal delivers it, and switches - sessions with modified arrow keys or modified tab numbers. +- Kept plain `q` and Ctrl-C available for the agent/terminal stream. The TUI + shell owns Alt chords: `Alt+Left/Right`, `Alt+1..9`, `Alt+n/r/s/t/d`, + `Alt+q`, `Alt+?`, `Alt+i`, and `Alt+o`. - Added `just dev-tui` for direct local playback. ## T04-T05 Closeout diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 9b83a757..6c66d6d3 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -30,8 +30,8 @@ preserves terminal colors and text attributes. - [x] Add client-side terminal output coalescing and dirty-frame redraws. - [x] Stabilize service latency width in the bottom bar. -- [x] Replace terminal-dependent Cmd/Ctrl navigation guesses with app-owned Alt - navigation plus a `Ctrl-b` prefix fallback. +- [x] Replace terminal-dependent Cmd/Ctrl navigation guesses with an app-owned + Alt namespace for shell controls. - [x] Add `capsem_terminal_snapshot` MCP tool for session terminal inspection. - [x] Add confirmed create/resume/suspend/stop/delete actions through the installed HTTP gateway. @@ -55,9 +55,8 @@ - Product correction after tmux reference review: removed aggregate VM status counts from the left, kept only service health/latency, colored only the active tab and attention tabs, and tied tab label color to the number tone. -- Keyboard policy: plain `q` and Ctrl-C belong to the agent/terminal stream, so - the standalone shell exits via F10, Ctrl-Esc, or Cmd-Q if the terminal emits - it. +- Keyboard policy: plain `q` and Ctrl-C belong to the agent/terminal stream. + The TUI shell exits with `Alt+q` and keeps app-owned controls under Alt. - Default `dev-tui` reads the installed HTTP gateway when available. It uses `CAPSEM_GATEWAY_URL` when set, otherwise the installed runtime `gateway.port`, otherwise `http://127.0.0.1:19222`. @@ -77,9 +76,10 @@ - MCP terminal inspection is now a text snapshot from service logs, not a bitmap screenshot. It is enough for agent debugging and works through the existing service log contract. -- Safe service actions are now active behind a confirmation overlay. F4 creates - an ephemeral session, F5 resumes stopped/suspended sessions, F6 suspends the - active session, F7 stops it, and F8 deletes it. Action calls run on a +- Safe service actions are now active behind a confirmation overlay. `Alt+n` + creates an ephemeral session, `Alt+r` resumes stopped/suspended sessions, + `Alt+s` suspends the active session, `Alt+t` stops it, and `Alt+d` deletes it. + Action calls run on a background worker so long suspend/stop/provision paths do not freeze terminal rendering. - Live VM proof is unblocked. MCP `capsem_list` reports asset health ready and @@ -99,9 +99,11 @@ `crossterm::event::poll`; the WebSocket path remains async and event-driven. - Service latency reserves four digits before `ms`, preventing the center tab strip from shifting when latency changes between one and four digits. -- Navigation is now app-owned: `Alt+h/l`, `Alt+Left/Right`, and `Alt+1..9` - switch sessions; `Ctrl-b` then `h/l/1..9` is a fallback prefix path when a - host environment eats Alt chords. +- Navigation is now app-owned: `Alt+Left/Right` switches sessions and + `Alt+1..9` jumps by tab number. `Alt+n/r/s/t/d`, `Alt+q`, `Alt+?`, `Alt+i`, + and `Alt+o` cover shell actions, exit, help, stats, and session list. +- `just dev-tui` documents the same Alt-only shell contract inline so local + playback and installed usage do not drift. - MCP triage for `tui-proof-a` found no session-level failures. Host triage still shows stale gateway terminal reconnect errors for the removed `crafty-panda` socket, which are unrelated to the proof sessions. From 43716abbf86926b3fa34fd0b7fd7dc13c1b33f99 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 19:09:48 -0400 Subject: [PATCH 15/35] fix: harden tui modal and resize behavior --- CHANGELOG.md | 5 ++++ crates/capsem-tui/src/app.rs | 2 +- crates/capsem-tui/src/main.rs | 53 +++++++++++++++++++++++++++------- crates/capsem-tui/src/tests.rs | 16 ++++++++-- crates/capsem-tui/src/ui.rs | 28 ++++++++++++++---- justfile | 2 +- sprints/tui-control/MASTER.md | 4 +++ sprints/tui-control/tracker.md | 14 +++++++-- 8 files changed, 100 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 513fb8ff..7b770389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,9 +121,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 redraws instead of a hand-rolled ANSI text flattener. - Fixed `capsem-tui` service latency rendering to reserve four digits so the bottom status bar does not shift as latency changes. +- Fixed `capsem-tui` service latency rendering to keep the status dot glued to + the latency field, making the service block read as one unit. - Fixed `capsem-tui` shell controls to use an app-owned Alt namespace: `Alt+Left/Right`, `Alt+1..9`, `Alt+n/r/s/t/d`, and `Alt+q`, instead of terminal-dependent Cmd/Ctrl forwarding or prefix fallbacks. +- Fixed `capsem-tui` help and modal handling by accepting both `Alt+?` and + `Alt+/`, rendering overlays through Ratatui modal widgets, and resending the + active terminal geometry whenever the real terminal size changes. - Fixed macOS release builds of the service debug report by widening filesystem block counts before computing disk byte totals. - Fixed macOS release builds of `capsem-process` shutdown handling by returning diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index 86981f0b..9629899b 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -179,7 +179,7 @@ impl App { return false; } let next = match key.code { - KeyCode::Char('?') => AppOverlay::Help, + KeyCode::Char('?' | '/') => AppOverlay::Help, KeyCode::Char('i' | 'I') => AppOverlay::Stats, KeyCode::Char('o' | 'O') => AppOverlay::Home, _ => return false, diff --git a/crates/capsem-tui/src/main.rs b/crates/capsem-tui/src/main.rs index 6eadfdea..287157a5 100644 --- a/crates/capsem-tui/src/main.rs +++ b/crates/capsem-tui/src/main.rs @@ -161,7 +161,7 @@ fn run_loop( ) -> Result<()> { let mut last_refresh = Instant::now(); let mut surface = TerminalSurface::new(); - let mut connected_session_id = String::new(); + let mut connected_terminal = None; let mut needs_draw = true; let input_events = spawn_input_reader(); loop { @@ -197,15 +197,16 @@ fn run_loop( } let size = terminal.size()?; let active_id = app.state().active_session_id.clone(); + let surface_rows = terminal_rows(size.height); if !active_id.is_empty() { - surface.resize(&active_id, size.width, size.height.saturating_sub(1)); + surface.resize(&active_id, size.width.max(1), surface_rows); } needs_draw |= sync_terminal_connection( app, bridge, - &mut connected_session_id, - size.width, - size.height.saturating_sub(1), + &mut connected_terminal, + size.width.max(1), + surface_rows, ); } if last_refresh.elapsed() >= refresh_interval { @@ -271,7 +272,7 @@ fn handle_terminal_event( }, Event::Resize(width, height) => { if let Some(bridge) = terminal_bridge { - bridge.resize(width, height.saturating_sub(1)); + bridge.resize(width.max(1), terminal_rows(height)); } } _ => {} @@ -322,20 +323,46 @@ enum ControlEvent { Finished(std::result::Result), } +#[derive(Clone, Debug, Eq, PartialEq)] +struct ConnectedTerminal { + session_id: String, + cols: u16, + rows: u16, +} + fn sync_terminal_connection( app: &App, bridge: &TerminalBridge, - connected_session_id: &mut String, + connected: &mut Option, cols: u16, rows: u16, ) -> bool { let active_id = &app.state().active_session_id; - if active_id.is_empty() || active_id == connected_session_id { + if active_id.is_empty() { return false; } - bridge.connect(active_id.clone(), cols, rows); - connected_session_id.clone_from(active_id); - true + let cols = cols.max(1); + let rows = rows.max(1); + match connected { + Some(current) if current.session_id == *active_id => { + if current.cols == cols && current.rows == rows { + return false; + } + bridge.resize(cols, rows); + current.cols = cols; + current.rows = rows; + true + } + _ => { + bridge.connect(active_id.clone(), cols, rows); + *connected = Some(ConnectedTerminal { + session_id: active_id.clone(), + cols, + rows, + }); + true + } + } } fn refresh_state(app: &mut App, provider: Option<&GatewayProvider>) -> bool { @@ -363,3 +390,7 @@ fn refresh_state(app: &mut App, provider: Option<&GatewayProvider>) -> bool { } } } + +fn terminal_rows(height: u16) -> u16 { + height.saturating_sub(1).max(1) +} diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 996e978f..0a470a0e 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -27,7 +27,7 @@ fn fixture_models_global_service_state_and_session_indicators() { fn snapshot_contains_light_bar_tabs_and_active_desktop() { let snapshot = render_snapshot(&fixture_state(), 100, 24).expect("render snapshot"); - assert!(snapshot.contains("● 18ms")); + assert!(snapshot.contains(" 18ms●")); assert!(snapshot.contains("1 profile-v2")); assert!(snapshot.contains("2 linux-os!")); assert!(snapshot.contains("◷ 47m | # 38.4k | $ 0.21")); @@ -138,10 +138,15 @@ fn function_keys_toggle_hidden_overlays() { assert_eq!(app.overlay(), AppOverlay::None); assert_eq!( - app.handle_key(key(KeyCode::Char('?'), KeyModifiers::ALT)), + app.handle_key(key(KeyCode::Char('/'), KeyModifiers::ALT)), AppAction::Consumed ); assert_eq!(app.overlay(), AppOverlay::Help); + assert_eq!( + app.handle_key(key(KeyCode::Char('?'), KeyModifiers::ALT)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::None); assert_eq!( app.handle_key(key(KeyCode::Char('i'), KeyModifiers::ALT)), AppAction::Consumed @@ -169,6 +174,13 @@ fn control_keys_require_confirmation_before_invoking_service_actions() { id: "profile-v2".to_string() }) ); + let modal_snapshot = render_app_snapshot(&app, 100, 24).expect("render confirmation"); + assert!(modal_snapshot.contains("confirm")); + assert!(modal_snapshot.contains("Enter confirms")); + assert!( + modal_snapshot.contains("┌"), + "confirmation should render as a modal block" + ); assert_eq!( app.handle_key(key(KeyCode::Char('x'), KeyModifiers::NONE)), diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 3533f7cb..cd4ef6cf 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -4,7 +4,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use ratatui::{Frame, Terminal}; use crate::app::{App, AppOverlay, ControlAction}; @@ -95,11 +95,12 @@ fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { let mut left = vec![ Span::styled(" ", base), + Span::styled(format!("{:>4}ms", service.latency.as_millis()), base), Span::styled( service_dot(service.status), service_style(service.status, service.latency.as_millis()), ), - Span::styled(format!(" {:>4}ms ", service.latency.as_millis()), base), + Span::styled(" ", base), ]; if let Some(attempt) = service.reconnect_attempt { left.push(Span::styled(format!(" reconnect {attempt}"), muted_style())); @@ -231,7 +232,21 @@ fn render_overlay( return; } let popup = centered_rect(area, 72, overlay_height(state, overlay)); - frame.render_widget(Paragraph::new("").style(status_base_style()), popup); + frame.render_widget(Clear, popup); + let title = match overlay { + AppOverlay::Help => " help ", + AppOverlay::Stats => " stats ", + AppOverlay::Home => " sessions ", + AppOverlay::Confirm => " confirm ", + AppOverlay::None => "", + }; + let block = Block::new() + .title(title) + .borders(Borders::ALL) + .border_style(muted_style()) + .style(status_base_style()) + .padding(Padding::horizontal(1)); + frame.render_widget(block, popup); let lines = match overlay { AppOverlay::Help => help_lines(), AppOverlay::Stats => stats_lines(state), @@ -262,9 +277,9 @@ fn centered_rect(area: Rect, width_percent: u16, height: u16) -> Rect { fn overlay_height(state: &AppState, overlay: AppOverlay) -> u16 { match overlay { - AppOverlay::Help => 8, - AppOverlay::Stats => 9, - AppOverlay::Home => (state.sessions.len() as u16).saturating_add(4).clamp(6, 14), + AppOverlay::Help => 10, + AppOverlay::Stats => 10, + AppOverlay::Home => (state.sessions.len() as u16).saturating_add(5).clamp(7, 16), AppOverlay::Confirm => 6, AppOverlay::None => 0, } @@ -278,6 +293,7 @@ fn help_lines() -> Vec> { overlay_line("Alt+n new Alt+r resume Alt+s suspend"), overlay_line("Alt+t stop Alt+d delete Alt+q quit"), overlay_line("Alt+? help Alt+i stats Alt+o sessions"), + overlay_line("Alt+/ also opens help when the terminal sends slash"), overlay_line("plain q, Ctrl-C, and shell keys pass through"), ] } diff --git a/justfile b/justfile index 69aa8f63..b8e7b197 100644 --- a/justfile +++ b/justfile @@ -258,7 +258,7 @@ dev-frontend: _pnpm-install # Standalone terminal control-plane shell. # App-owned controls: Alt+Left/Right switch sessions; Alt+1..9 jumps; # Alt+n new, Alt+r resume, Alt+s suspend, Alt+t stop, Alt+d delete, Alt+q quit; -# Alt+? help, Alt+i stats, Alt+o sessions. Plain q/Ctrl-C pass to the VM. +# Alt+?/Alt+/ help, Alt+i stats, Alt+o sessions. Plain q/Ctrl-C pass to the VM. # Pass extra args after `--`: `just dev-tui -- --snapshot`. dev-tui *ARGS: cargo run -p capsem-tui {{ARGS}} diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 685ffb39..beb7cf7b 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -105,6 +105,10 @@ attention markers. screen state, SGR colors, and text attributes. Client-side adjacent output coalescing and dirty-frame redraws now mirror the existing `capsem shell` speed contract instead of repainting on every loop. +- Tightened interactive control polish: help opens on both `Alt+?` and + terminal-encoded `Alt+/`, overlays render as Ratatui modal blocks, service + latency renders as a glued `####ms●` segment, and active terminal geometry is + resent whenever the real terminal size changes. - Kept richer missing state explicit for future API work: waiting-for-input, terminal bell, per-session repo/path metadata, security/enforcement/detection totals, and event cursor semantics are not invented by the TUI. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 6c66d6d3..994c9b80 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -99,9 +99,17 @@ `crossterm::event::poll`; the WebSocket path remains async and event-driven. - Service latency reserves four digits before `ms`, preventing the center tab strip from shifting when latency changes between one and four digits. +- Service latency now renders as `####ms●`, with the status dot glued to the + reserved latency field so it reads as one service-health segment. - Navigation is now app-owned: `Alt+Left/Right` switches sessions and - `Alt+1..9` jumps by tab number. `Alt+n/r/s/t/d`, `Alt+q`, `Alt+?`, `Alt+i`, - and `Alt+o` cover shell actions, exit, help, stats, and session list. + `Alt+1..9` jumps by tab number. `Alt+n/r/s/t/d`, `Alt+q`, `Alt+?`/`Alt+/`, + `Alt+i`, and `Alt+o` cover shell actions, exit, help, stats, and session + list. +- Interactive terminal resize now tracks the active session and geometry + together, so a pure terminal resize resends the guest PTY size even when the + selected VM did not change. +- Help, stats, sessions, and confirmation overlays now use Ratatui `Clear` and + bordered modal blocks instead of drawing loose text over terminal output. - `just dev-tui` documents the same Alt-only shell contract inline so local playback and installed usage do not drift. - MCP triage for `tui-proof-a` found no session-level failures. Host triage @@ -110,7 +118,7 @@ ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui` (18 tests). +- Unit/contract: `cargo test -p capsem-tui` (21 tests). - Formatting: `cargo fmt -p capsem-tui -- --check`. - Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`; `cargo run -p capsem-tui -- --fixture --snapshot --width 120 --height 30`; From 161e40f4f6571fafed310e39ff283fc6fb73ea28 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 19:16:02 -0400 Subject: [PATCH 16/35] fix: simplify tui tab colors and modal input --- CHANGELOG.md | 5 +++ crates/capsem-tui/src/app.rs | 6 +++ crates/capsem-tui/src/main.rs | 3 +- crates/capsem-tui/src/tests.rs | 78 +++++++++++++++++++++++++++++++++- crates/capsem-tui/src/ui.rs | 25 +++++------ sprints/tui-control/MASTER.md | 3 ++ sprints/tui-control/tracker.md | 8 +++- 7 files changed, 113 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b770389..c3b06ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,6 +129,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed `capsem-tui` help and modal handling by accepting both `Alt+?` and `Alt+/`, rendering overlays through Ratatui modal widgets, and resending the active terminal geometry whenever the real terminal size changes. +- Fixed `capsem-tui` modal input ownership so `Esc` closes non-confirmation + overlays, visible modals consume normal keys, and plain VM input resumes + forwarding as soon as the modal closes. +- Fixed `capsem-tui` tab colors so the selected VM is yellow and every other + VM tab is blue, removing the previous gray/attention color ambiguity. - Fixed macOS release builds of the service debug report by widening filesystem block counts before computing disk byte totals. - Fixed macOS release builds of `capsem-process` shutdown handling by returning diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index 9629899b..fd3158fd 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -119,6 +119,12 @@ impl App { if self.handle_overlay_key(key) { return AppAction::Consumed; } + if self.overlay != AppOverlay::None { + if key.code == KeyCode::Esc { + self.overlay = AppOverlay::None; + } + return AppAction::Consumed; + } if let Some(action) = self.control_action_for_key(key) { self.pending_action = Some(action); self.overlay = AppOverlay::Confirm; diff --git a/crates/capsem-tui/src/main.rs b/crates/capsem-tui/src/main.rs index 287157a5..aed65fbd 100644 --- a/crates/capsem-tui/src/main.rs +++ b/crates/capsem-tui/src/main.rs @@ -12,7 +12,7 @@ use capsem_tui::provider::StateProvider; use capsem_tui::terminal::{key_to_terminal_bytes, TerminalBridge, TerminalSurface}; use capsem_tui::ui::{render_app, render_snapshot, render_svg_snapshot}; use clap::Parser; -use crossterm::event::{self, Event}; +use crossterm::event::{self, Event, KeyEventKind}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -254,6 +254,7 @@ fn handle_terminal_event( control_bridge: Option<&ControlBridge>, ) -> Result { match event { + Event::Key(key) if matches!(key.kind, KeyEventKind::Release) => {} Event::Key(key) => match app.handle_key(key) { AppAction::Exit => return Ok(true), AppAction::Consumed => {} diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 0a470a0e..d9f3c3e0 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -1,11 +1,12 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::style::{Color, Modifier}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use crate::app::{App, AppAction, AppOverlay, ControlAction}; use crate::fixture::fixture_state; use crate::gateway_provider::{state_from_status_json_for_test, GatewayProvider}; use crate::model::{Attention, ServiceStatus, SessionLifecycle}; -use crate::ui::{render_app_snapshot, render_snapshot}; +use crate::ui::{render_app_snapshot, render_snapshot, render_test_buffer}; #[test] fn fixture_models_global_service_state_and_session_indicators() { @@ -45,6 +46,31 @@ fn snapshot_contains_light_bar_tabs_and_active_desktop() { ); } +#[test] +fn tab_colors_use_selected_yellow_and_unselected_blue_only() { + let buffer = render_test_buffer(&fixture_state(), 100, 24).expect("render buffer"); + let row = buffer.area.height - 1; + let selected_number = find_cell_x(&buffer, row, "1 profile-v2"); + let selected_label = selected_number + 3; + let other_number = find_cell_x(&buffer, row, "2 linux-os!"); + let other_label = other_number + 3; + + assert_eq!(buffer_cell(&buffer, selected_number, row).bg, yellow()); + assert_eq!(buffer_cell(&buffer, selected_label, row).fg, yellow()); + assert!(buffer_cell(&buffer, selected_number, row) + .modifier + .contains(Modifier::BOLD)); + + assert_eq!(buffer_cell(&buffer, other_number, row).bg, blue()); + assert_eq!(buffer_cell(&buffer, other_label, row).fg, blue()); + assert!( + !buffer_cell(&buffer, other_label, row) + .modifier + .contains(Modifier::BOLD), + "only the selected tab label should be bold" + ); +} + #[test] fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { let mut app = App::new(fixture_state()); @@ -159,6 +185,32 @@ fn function_keys_toggle_hidden_overlays() { assert_eq!(app.overlay(), AppOverlay::None); } +#[test] +fn esc_closes_modal_overlays_and_restores_vm_input() { + let mut app = App::new(fixture_state()); + + assert_eq!( + app.handle_key(key(KeyCode::Char('/'), KeyModifiers::ALT)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::Help); + assert_eq!( + app.handle_key(key(KeyCode::Char('x'), KeyModifiers::NONE)), + AppAction::Consumed, + "modal overlays should own keys while visible" + ); + assert_eq!( + app.handle_key(key(KeyCode::Esc, KeyModifiers::NONE)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::None); + assert_eq!( + app.handle_key(key(KeyCode::Char('x'), KeyModifiers::NONE)), + AppAction::Forward, + "plain VM input must forward after the modal closes" + ); +} + #[test] fn control_keys_require_confirmation_before_invoking_service_actions() { let mut app = App::new(fixture_state()); @@ -428,6 +480,30 @@ fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { KeyEvent::new(code, modifiers) } +fn find_cell_x(buffer: &ratatui::buffer::Buffer, row: u16, needle: &str) -> u16 { + let width = buffer.area.width as usize; + let row_start = row as usize * width; + let line = buffer.content()[row_start..row_start + width] + .iter() + .map(|cell| cell.symbol()) + .collect::(); + let byte_index = line.find(needle).expect("needle in rendered row"); + line[..byte_index].chars().count() as u16 +} + +fn buffer_cell(buffer: &ratatui::buffer::Buffer, x: u16, y: u16) -> &ratatui::buffer::Cell { + let width = buffer.area.width as usize; + &buffer.content()[y as usize * width + x as usize] +} + +fn yellow() -> Color { + Color::Rgb(249, 226, 175) +} + +fn blue() -> Color { + Color::Rgb(137, 180, 250) +} + async fn read_http_request(stream: &mut tokio::net::TcpStream) -> String { let mut request = Vec::new(); let mut buffer = [0_u8; 256]; diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index cd4ef6cf..604fb0a0 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -83,6 +83,11 @@ fn render_buffer(state: &AppState, width: u16, height: u16) -> Result { Ok(terminal.backend().buffer().clone()) } +#[cfg(test)] +pub(crate) fn render_test_buffer(state: &AppState, width: u16, height: u16) -> Result { + render_buffer(state, width, height) +} + fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { let service = &state.service; let active_index = state @@ -427,7 +432,7 @@ fn push_tab( max_width: usize, used: &mut usize, ) -> bool { - let tone = TabTone::from_session(session, active); + let tone = TabTone::from_active(active); let number = format!(" {} ", index + 1); let label = format!( " {}{} ", @@ -506,27 +511,23 @@ fn stats_style() -> Style { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum TabTone { - Active, - Attention, - Normal, + Selected, + Unselected, } impl TabTone { - fn from_session(session: &SessionSummary, active: bool) -> Self { + const fn from_active(active: bool) -> Self { if active { - Self::Active - } else if session.attention.is_empty() { - Self::Normal + Self::Selected } else { - Self::Attention + Self::Unselected } } const fn color(self) -> Color { match self { - Self::Active => ACTIVE, - Self::Attention => ATTENTION, - Self::Normal => MUTED, + Self::Selected => ATTENTION, + Self::Unselected => ACTIVE, } } } diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index beb7cf7b..864ccadd 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -109,6 +109,9 @@ attention markers. terminal-encoded `Alt+/`, overlays render as Ratatui modal blocks, service latency renders as a glued `####ms●` segment, and active terminal geometry is resent whenever the real terminal size changes. +- Simplified human-facing tab colors: selected VM is yellow, every other VM tab + is blue. Modal overlays now close with `Esc`, own normal keys while visible, + and release VM input forwarding immediately after close. - Kept richer missing state explicit for future API work: waiting-for-input, terminal bell, per-session repo/path metadata, security/enforcement/detection totals, and event cursor semantics are not invented by the TUI. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 994c9b80..6e6d184c 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -105,11 +105,17 @@ `Alt+1..9` jumps by tab number. `Alt+n/r/s/t/d`, `Alt+q`, `Alt+?`/`Alt+/`, `Alt+i`, and `Alt+o` cover shell actions, exit, help, stats, and session list. +- Tab colors now use one semantic: selected is yellow, every other VM tab is + blue. Bell/attention state keeps its text marker but no longer changes the + tab color. - Interactive terminal resize now tracks the active session and geometry together, so a pure terminal resize resends the guest PTY size even when the selected VM did not change. - Help, stats, sessions, and confirmation overlays now use Ratatui `Clear` and bordered modal blocks instead of drawing loose text over terminal output. +- Help, stats, and sessions are real modals: `Esc` closes them, visible modals + consume normal keys, and plain VM input forwards again immediately after + close. Key-release events are ignored in the interactive loop. - `just dev-tui` documents the same Alt-only shell contract inline so local playback and installed usage do not drift. - MCP triage for `tui-proof-a` found no session-level failures. Host triage @@ -118,7 +124,7 @@ ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui` (21 tests). +- Unit/contract: `cargo test -p capsem-tui` (23 tests). - Formatting: `cargo fmt -p capsem-tui -- --check`. - Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`; `cargo run -p capsem-tui -- --fixture --snapshot --width 120 --height 30`; From 6601d6f2691211020271111d1e40a98fb89570be Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 19:57:38 -0400 Subject: [PATCH 17/35] fix: close process IPC helper tasks --- CHANGELOG.md | 4 ++ crates/capsem-process/src/ipc.rs | 57 ++++++++++++++++---------- crates/capsem-process/src/ipc/tests.rs | 38 +++++++++++++++++ sprints/tui-control/tracker.md | 12 +++++- 4 files changed, 88 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b06ed0..f9449f31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added Linux host doctor smoke probes for `KVM_GET_API_VERSION` and `/dev/vhost-vsock` openability so bootstrap verifies usable KVM devices, not just filesystem permissions. + - Added Linux KVM doctor coverage that creates and resolves symlinks under `/tmp`, keeping link-heavy cache/tool probes off the VirtioFS workspace while leaving snapshot symlink restore scoped to `/root`. @@ -113,6 +114,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cold-booting. ### Fixed +- Fixed a `capsem-process` IPC file-descriptor leak where short-lived + status/metrics connections left writer and lifecycle-forwarder tasks alive + after the client disconnected. - Fixed `capsem-tui` live gateway attention handling so sessions with `profile_status=current` are not marked stale, and proved the installed terminal WebSocket path against two running service sessions. diff --git a/crates/capsem-process/src/ipc.rs b/crates/capsem-process/src/ipc.rs index 7f36726f..3412721d 100644 --- a/crates/capsem-process/src/ipc.rs +++ b/crates/capsem-process/src/ipc.rs @@ -102,7 +102,7 @@ pub(crate) async fn handle_ipc_connection( // Sender::send() writes header + payload as two separate syscalls with no // internal locking, so concurrent use from multiple tasks is unsafe. let (ipc_tx_out, mut ipc_rx_out) = mpsc::channel::(256); - tokio::spawn(async move { + let writer_task = tokio::spawn(async move { while let Some(msg) = ipc_rx_out.recv().await { if tx.send(msg).await.is_err() { break; @@ -115,20 +115,7 @@ pub(crate) async fn handle_ipc_connection( // is high-volume and still opt-in via StartTerminalStream. Without this, // a suspend-only connection never sees StateChanged { state: "Suspended" } // and the service times out waiting for confirmation. - { - let out_tx = ipc_tx_out.clone(); - let mut rx_bcast = ipc_tx.subscribe(); - tokio::spawn(async move { - while let Ok(msg) = rx_bcast.recv().await { - if matches!(msg, ProcessToService::TerminalOutput { .. }) { - continue; - } - if out_tx.send(msg).await.is_err() { - break; - } - } - }); - } + let lifecycle_task = spawn_lifecycle_forwarder(&ipc_tx, ipc_tx_out.clone()); // Live stream task spawned by StartTerminalStream. Held here so // StopTerminalStream and connection teardown can abort it instead of @@ -216,7 +203,7 @@ pub(crate) async fn handle_ipc_connection( ); } else { debug!("Ping received but VM not ready, closing connection"); - return Ok(()); + break; } } ServiceToProcess::GetMetricsSnapshot { id } => { @@ -658,15 +645,41 @@ pub(crate) async fn handle_ipc_connection( } } } - // Connection ended: cancel any in-flight stream task. Without this the - // task lives on the runtime, holds its `out_tx`, and may attempt one - // more send after the client has already closed the IPC socket -- - // benign for the underlying mpsc but a leak (the receiver's drop - // chain finishes one tick later than necessary). + // Connection ended: cancel every per-connection helper. The writer owns + // the IPC sender/socket, and the lifecycle forwarder owns an `out_tx` + // clone; leaving them alive after request/response clients disconnect + // leaks tasks and file descriptors under status/metrics polling. + abort_connection_tasks(&mut stream_task, &lifecycle_task, &writer_task); + Ok(()) +} + +fn spawn_lifecycle_forwarder( + ipc_tx: &broadcast::Sender, + out_tx: mpsc::Sender, +) -> tokio::task::JoinHandle<()> { + let mut rx_bcast = ipc_tx.subscribe(); + tokio::spawn(async move { + while let Ok(msg) = rx_bcast.recv().await { + if matches!(msg, ProcessToService::TerminalOutput { .. }) { + continue; + } + if out_tx.send(msg).await.is_err() { + break; + } + } + }) +} + +fn abort_connection_tasks( + stream_task: &mut Option>, + lifecycle_task: &tokio::task::JoinHandle<()>, + writer_task: &tokio::task::JoinHandle<()>, +) { if let Some(h) = stream_task.take() { h.abort(); } - Ok(()) + lifecycle_task.abort(); + writer_task.abort(); } /// Maps an IPC ServiceToProcess message to the action category it triggers. diff --git a/crates/capsem-process/src/ipc/tests.rs b/crates/capsem-process/src/ipc/tests.rs index 3ddd84af..450b77e4 100644 --- a/crates/capsem-process/src/ipc/tests.rs +++ b/crates/capsem-process/src/ipc/tests.rs @@ -3,6 +3,38 @@ use std::time::Duration; use super::*; use tokio::sync::oneshot; +#[tokio::test] +async fn connection_teardown_aborts_writer_and_lifecycle_tasks() { + let (ipc_tx_out, mut ipc_rx_out) = mpsc::channel::(1); + let (ipc_tx, _) = broadcast::channel::(1); + let writer_task = tokio::spawn(async move { while ipc_rx_out.recv().await.is_some() {} }); + let lifecycle_task = spawn_lifecycle_forwarder(&ipc_tx, ipc_tx_out.clone()); + drop(ipc_tx_out); + + tokio::time::sleep(Duration::from_millis(10)).await; + assert!( + !writer_task.is_finished(), + "writer task should stay alive while lifecycle forwarder holds out_tx" + ); + assert!( + !lifecycle_task.is_finished(), + "lifecycle forwarder should stay alive until connection teardown" + ); + + let mut stream_task = None; + abort_connection_tasks(&mut stream_task, &lifecycle_task, &writer_task); + + let writer_result = tokio::time::timeout(Duration::from_secs(1), writer_task) + .await + .expect("writer task should finish after teardown"); + assert!(writer_result.unwrap_err().is_cancelled()); + + let lifecycle_result = tokio::time::timeout(Duration::from_secs(1), lifecycle_task) + .await + .expect("lifecycle task should finish after teardown"); + assert!(lifecycle_result.unwrap_err().is_cancelled()); +} + #[tokio::test] async fn exec_wait_has_no_internal_deadline() { let (_tx, rx) = oneshot::channel(); @@ -108,8 +140,14 @@ fn metrics_snapshot_is_process_owned_and_versioned() { assert_eq!(snapshot.resources.configured_vcpus, 4); assert_eq!(snapshot.resources.configured_ram_mb, 8192); assert_eq!(snapshot.resources.host_pid, Some(std::process::id())); + #[cfg(target_os = "linux")] assert!(snapshot.resources.host_process_rss_bytes.unwrap_or(0) > 0); + #[cfg(not(target_os = "linux"))] + assert!(snapshot.resources.host_process_rss_bytes.is_none()); + #[cfg(target_os = "linux")] assert!(snapshot.resources.host_cpu_time_micros.is_some()); + #[cfg(not(target_os = "linux"))] + assert!(snapshot.resources.host_cpu_time_micros.is_none()); assert_eq!(snapshot.resources.workspace_disk_bytes, Some(4)); assert_eq!(snapshot.resources.rootfs_overlay_bytes, Some(7)); assert_eq!(snapshot.resources.session_disk_bytes, Some(11)); diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 6e6d184c..e2a49d55 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -121,11 +121,20 @@ - MCP triage for `tui-proof-a` found no session-level failures. Host triage still shows stale gateway terminal reconnect errors for the removed `crafty-panda` socket, which are unrelated to the proof sessions. +- Bug fix: status/metrics IPC polling was leaking per-connection writer and + lifecycle-forwarder tasks in `capsem-process`, eventually exhausting file + descriptors under the two-VM TUI proof. Teardown now aborts every + per-connection helper. +- Live fd stress after install: 150 service `/list` refreshes across + `tui-proof-a` and `tui-proof-b` kept process fd counts flat at 39 and 40. ## Coverage Ledger - Unit/contract: `cargo test -p capsem-tui` (23 tests). +- Process IPC: `cargo test -p capsem-process` (120 tests), including + `connection_teardown_aborts_writer_and_lifecycle_tasks`. - Formatting: `cargo fmt -p capsem-tui -- --check`. +- Process formatting: `cargo fmt -p capsem-process -- --check`. - Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`; `cargo run -p capsem-tui -- --fixture --snapshot --width 120 --height 30`; `cargo run -p capsem-tui -- --fixture --snapshot-svg --width 120 --height 30`; @@ -144,7 +153,8 @@ - E2E/VM: live installed-gateway empty-service snapshot works; live multi-session terminal proof works with MCP-created `tui-proof-a` and `tui-proof-b`; installed gateway terminal WebSocket returned VM shell output - from `tui-proof-a`. + from `tui-proof-a`; post-fix installed fd stress held both VM process fd + counts flat through repeated service metrics polling. - Telemetry: current gateway `/status` counters mapped; event-stream semantics still open. - Performance: not measured yet. From a21e269c34f5eb9993f6198b635e65e00bfc42f0 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 20:13:43 -0400 Subject: [PATCH 18/35] fix: stabilize tui latency display --- CHANGELOG.md | 5 ++ crates/capsem-tui/src/app.rs | 26 ++++++++- crates/capsem-tui/src/gateway_provider.rs | 51 ++++++++++++++--- crates/capsem-tui/src/tests.rs | 67 +++++++++++++++++++++++ sprints/tui-control/tracker.md | 11 +++- 5 files changed, 151 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9449f31..46dfa1ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed the guest rootfs build default to a configurable 128K squashfs block size, improving measured CLI startup and sequential rootfs reads while recording the chunk-size choice in `guest/config/build.toml`. +- Changed `capsem-tui` gateway refreshes to reuse the HTTP client and cached + gateway token, so status polling measures the local status request instead of + redoing auth bootstrap on every tick. - Strengthened the suspend/resume lifecycle integration test so it now proves a background guest process keeps the same PID and continues writing after warm resume, giving Apple VZ and KVM the same long-term state-preservation @@ -127,6 +130,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 bottom status bar does not shift as latency changes. - Fixed `capsem-tui` service latency rendering to keep the status dot glued to the latency field, making the service block read as one unit. +- Fixed `capsem-tui` local latency jitter by smoothing sub-100ms cache-hit + versus cache-refresh measurements while still surfacing real slow responses. - Fixed `capsem-tui` shell controls to use an app-owned Alt namespace: `Alt+Left/Right`, `Alt+1..9`, `Alt+n/r/s/t/d`, and `Alt+q`, instead of terminal-dependent Cmd/Ctrl forwarding or prefix fallbacks. diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index fd3158fd..c4b247b1 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -1,6 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; -use crate::model::{AppState, SessionLifecycle}; +use crate::model::{AppState, ServiceStatus, SessionLifecycle}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum AppAction { @@ -88,6 +89,12 @@ impl App { pub fn replace_state(&mut self, mut state: AppState) { state.service.control_message = self.state.service.control_message.clone(); + if matches!(self.state.service.status, ServiceStatus::Online) + && matches!(state.service.status, ServiceStatus::Online) + { + state.service.latency = + stable_local_latency(self.state.service.latency, state.service.latency); + } let previous_active_id = self.state.active_session_id.clone(); if state .sessions @@ -260,6 +267,23 @@ impl App { } } +fn stable_local_latency(previous: Duration, next: Duration) -> Duration { + const LOCAL_JITTER_CEILING: Duration = Duration::from_millis(100); + if previous == Duration::ZERO + || previous >= LOCAL_JITTER_CEILING + || next >= LOCAL_JITTER_CEILING + { + return next; + } + let previous_micros = previous.as_micros(); + let next_micros = next.as_micros(); + let smoothed = previous_micros + .saturating_mul(3) + .saturating_add(next_micros) + / 4; + Duration::from_micros(smoothed.min(u64::MAX as u128) as u64) +} + fn is_exit_key(key: KeyEvent) -> bool { matches!( (key.code, key.modifiers), diff --git a/crates/capsem-tui/src/gateway_provider.rs b/crates/capsem-tui/src/gateway_provider.rs index 60122d73..f78b2363 100644 --- a/crates/capsem-tui/src/gateway_provider.rs +++ b/crates/capsem-tui/src/gateway_provider.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; @@ -11,15 +12,53 @@ use crate::model::{ }; use crate::provider::StateProvider; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug)] pub struct GatewayProvider { base_url: String, + client: reqwest::Client, + token: Arc>>, +} + +impl PartialEq for GatewayProvider { + fn eq(&self, other: &Self) -> bool { + self.base_url == other.base_url + } +} + +impl Eq for GatewayProvider {} + +impl GatewayProvider { + fn auth_token(&self) -> Result> { + self.token + .lock() + .map(|token| token.clone()) + .map_err(|_| anyhow::anyhow!("capsem gateway token cache poisoned")) + } + + fn store_auth_token(&self, token: String) -> Result { + let mut cached = self + .token + .lock() + .map_err(|_| anyhow::anyhow!("capsem gateway token cache poisoned"))?; + *cached = Some(token.clone()); + Ok(token) + } + + async fn token(&self) -> Result { + if let Some(token) = self.auth_token()? { + return Ok(token); + } + let token = fetch_token(&self.client, &self.base_url).await?; + self.store_auth_token(token) + } } impl GatewayProvider { pub fn new(base_url: String) -> Self { Self { base_url: base_url.trim_end_matches('/').to_string(), + client: reqwest::Client::new(), + token: Arc::new(Mutex::new(None)), } } @@ -36,10 +75,9 @@ impl GatewayProvider { } pub async fn load_async(&self) -> Result { + let token = self.token().await?; let started = Instant::now(); - let client = reqwest::Client::new(); - let token = fetch_token(&client, &self.base_url).await?; - let status = fetch_status(&client, &self.base_url, &token).await?; + let status = fetch_status(&self.client, &self.base_url, &token).await?; Ok(status_response_to_state(status, started.elapsed())) } @@ -52,9 +90,8 @@ impl GatewayProvider { } pub async fn invoke_async(&self, action: &ControlAction) -> Result { - let client = reqwest::Client::new(); - let token = fetch_token(&client, &self.base_url).await?; - invoke_action(&client, &self.base_url, &token, action).await + let token = self.token().await?; + invoke_action(&self.client, &self.base_url, &token, action).await } } diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index d9f3c3e0..acc4218f 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -110,6 +110,32 @@ fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { ); } +#[test] +fn replace_state_smooths_local_service_latency_jitter() { + let mut initial = fixture_state(); + initial.service.latency = std::time::Duration::from_millis(1); + let mut app = App::new(initial); + + let mut cache_refresh = fixture_state(); + cache_refresh.service.latency = std::time::Duration::from_millis(7); + app.replace_state(cache_refresh); + + assert_eq!( + app.state().service.latency, + std::time::Duration::from_micros(2_500) + ); + + let mut real_slow = fixture_state(); + real_slow.service.latency = std::time::Duration::from_millis(150); + app.replace_state(real_slow); + + assert_eq!( + app.state().service.latency, + std::time::Duration::from_millis(150), + "real degraded latency should not be hidden by local jitter smoothing" + ); +} + #[test] fn shell_commands_are_alt_owned() { let mut app = App::new(fixture_state()); @@ -399,6 +425,47 @@ async fn gateway_provider_loads_status_over_http_gateway() { server.await.expect("server task"); } +#[tokio::test] +async fn gateway_provider_reuses_token_across_status_refreshes() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test gateway"); + let addr = listener.local_addr().expect("local addr"); + let body = gateway_status_body().to_string(); + let server = tokio::spawn(async move { + let mut token_requests = 0; + let mut status_requests = 0; + for _ in 0..3 { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let request = read_http_request(&mut stream).await; + if request.contains("GET /token ") { + token_requests += 1; + write_json_response(&mut stream, r#"{"token":"test-token"}"#).await; + } else { + status_requests += 1; + assert!( + request.contains("GET /status "), + "unexpected request: {request:?}" + ); + assert!( + request.contains("authorization: Bearer test-token") + || request.contains("Authorization: Bearer test-token"), + "missing bearer auth: {request:?}" + ); + write_json_response(&mut stream, &body).await; + } + } + assert_eq!(token_requests, 1, "token should be cached across refreshes"); + assert_eq!(status_requests, 2); + }); + + let provider = GatewayProvider::new(format!("http://{addr}")); + provider.load_async().await.expect("initial load"); + provider.load_async().await.expect("refresh load"); + + server.await.expect("server task"); +} + #[tokio::test] async fn gateway_provider_invokes_stop_over_authenticated_gateway() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index e2a49d55..fe4b2001 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -127,10 +127,19 @@ per-connection helper. - Live fd stress after install: 150 service `/list` refreshes across `tui-proof-a` and `tui-proof-b` kept process fd counts flat at 39 and 40. +- Local latency diagnosis: gateway `/status` is stable at roughly 4-8ms when + refreshing the two proof VMs. The visible `0/1ms` versus `7ms` jitter came + from the TUI displaying raw cache-hit/cache-refresh phase and paying token + bootstrap on every refresh. +- TUI latency fix: gateway refreshes now reuse the HTTP client and cached + gateway token, and interactive state replacement smooths sub-100ms local + latency jitter without hiding real slow responses. ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui` (23 tests). +- Unit/contract: `cargo test -p capsem-tui` (25 tests). +- TUI latency/provider: `cargo test -p capsem-tui` (25 tests), including + token reuse and local latency smoothing coverage. - Process IPC: `cargo test -p capsem-process` (120 tests), including `connection_teardown_aborts_writer_and_lifecycle_tasks`. - Formatting: `cargo fmt -p capsem-tui -- --check`. From 6138c0b9cc3365aa6a209e0ec2bb61307d565e11 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 21:09:22 -0400 Subject: [PATCH 19/35] fix: gate endpoint latency hot paths --- CHANGELOG.md | 15 +- Cargo.toml | 4 +- .../data_1.2.1780103109_arm64.json | 751 ++++++++++++++++++ crates/capsem-app/tauri.conf.json | 2 +- crates/capsem-core/src/session/index.rs | 15 +- crates/capsem-logger/src/reader.rs | 85 +- crates/capsem-process/src/ipc.rs | 37 +- crates/capsem-process/src/ipc/tests.rs | 20 +- crates/capsem-process/src/main.rs | 1 - crates/capsem-service/src/main.rs | 63 +- crates/capsem-service/src/tests.rs | 32 + crates/capsem-tui/src/app.rs | 26 +- crates/capsem-tui/src/tests.rs | 21 +- pyproject.toml | 2 +- skills/dev-benchmark/SKILL.md | 38 + sprints/tui-control/MASTER.md | 11 +- sprints/tui-control/tracker.md | 35 +- .../test_endpoint_latency_benchmark.py | 302 +++++++ uv.lock | 2 +- 19 files changed, 1291 insertions(+), 171 deletions(-) create mode 100644 benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json create mode 100644 tests/capsem-serial/test_endpoint_latency_benchmark.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dfa1ec..5f81da2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 source selection, and tailing. - Recorded macOS arm64 benchmark data for `1.2.1779673506`, including in-VM, lifecycle, fork, and security-engine benchmark results. +- Added an 8-live-VM host endpoint latency benchmark under + `tests/capsem-serial/test_endpoint_latency_benchmark.py`, covering global + service reads, per-VM detail/history/file/policy-context reads, and gateway + health/token/status reads with committed `benchmarks/endpoint-latency/` + results. ### Changed - Split Google into its own `sprints/google/` meta sprint covering Gmail, @@ -74,6 +79,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed `capsem-tui` gateway refreshes to reuse the HTTP client and cached gateway token, so status polling measures the local status request instead of redoing auth bootstrap on every tick. +- Changed `capsem-process` live metrics snapshots to stay on in-memory + counters instead of recursively scanning VM session directories on the + service `/list` hot path. +- Changed service read hot paths so `/list` no longer calls per-VM live metrics, + `/stats` uses an empty/read-only fast path, raw session DB queries use + SQLite progress handlers instead of a 100ms watchdog-thread floor, and + policy-context exports no longer duplicate one security event across multiple + joined detail rows. - Strengthened the suspend/resume lifecycle integration test so it now proves a background guest process keeps the same PID and continues writing after warm resume, giving Apple VZ and KVM the same long-term state-preservation @@ -130,8 +143,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 bottom status bar does not shift as latency changes. - Fixed `capsem-tui` service latency rendering to keep the status dot glued to the latency field, making the service block read as one unit. -- Fixed `capsem-tui` local latency jitter by smoothing sub-100ms cache-hit - versus cache-refresh measurements while still surfacing real slow responses. - Fixed `capsem-tui` shell controls to use an app-owned Alt namespace: `Alt+Left/Right`, `Alt+1..9`, `Alt+n/r/s/t/d`, and `Alt+q`, instead of terminal-dependent Cmd/Ctrl forwarding or prefix fallbacks. diff --git a/Cargo.toml b/Cargo.toml index f5a9ed9d..3993b4f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ members = [ ] [workspace.package] -version = "1.2.1779673506" +version = "1.2.1780103109" edition = "2021" rust-version = "1.91" license = "Apache-2.0" @@ -70,7 +70,7 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["raw_value"] } rmp-serde = "1.3.0" toml = "0.8" -rusqlite = { version = "0.32", features = ["bundled"] } +rusqlite = { version = "0.32", features = ["bundled", "hooks"] } humantime = "2" objc2 = "0.6" objc2-virtualization = { version = "0.3", features = [ diff --git a/benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json b/benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json new file mode 100644 index 00000000..0e93bcf4 --- /dev/null +++ b/benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json @@ -0,0 +1,751 @@ +{ + "version": "0.1.0", + "timestamp": 1780103291.448705, + "vm_count": 8, + "iterations": { + "service_global": 16, + "service_vm": 4, + "gateway": 32 + }, + "gates": { + "service_global": { + "p95_ms": 3.0, + "max_ms": 10.0 + }, + "service_vm": { + "p95_ms": 12.0, + "max_ms": 35.0 + }, + "gateway": { + "p95_ms": 2.0, + "max_ms": 8.0 + } + }, + "groups": { + "service_global": { + "/version": { + "count": 16, + "min_ms": 0.051, + "p50_ms": 0.056, + "p95_ms": 0.088, + "p99_ms": 0.088, + "max_ms": 0.088 + }, + "/list": { + "count": 16, + "min_ms": 0.177, + "p50_ms": 0.191, + "p95_ms": 0.335, + "p99_ms": 0.335, + "max_ms": 0.335 + }, + "/stats": { + "count": 16, + "min_ms": 0.51, + "p50_ms": 0.602, + "p95_ms": 0.798, + "p99_ms": 0.798, + "max_ms": 0.798 + }, + "/settings": { + "count": 16, + "min_ms": 0.234, + "p50_ms": 0.253, + "p95_ms": 0.338, + "p99_ms": 0.338, + "max_ms": 0.338 + }, + "/settings/presets": { + "count": 16, + "min_ms": 0.204, + "p50_ms": 0.23, + "p95_ms": 0.421, + "p99_ms": 0.421, + "max_ms": 0.421 + }, + "/profiles": { + "count": 16, + "min_ms": 0.369, + "p50_ms": 0.406, + "p95_ms": 0.532, + "p99_ms": 0.532, + "max_ms": 0.532 + }, + "/profiles/catalog": { + "count": 16, + "min_ms": 0.094, + "p50_ms": 0.105, + "p95_ms": 0.123, + "p99_ms": 0.123, + "max_ms": 0.123 + }, + "/rules": { + "count": 16, + "min_ms": 0.169, + "p50_ms": 0.182, + "p95_ms": 0.213, + "p99_ms": 0.213, + "max_ms": 0.213 + }, + "/enforcement": { + "count": 16, + "min_ms": 0.061, + "p50_ms": 0.071, + "p95_ms": 0.089, + "p99_ms": 0.089, + "max_ms": 0.089 + }, + "/enforcement/stats": { + "count": 16, + "min_ms": 0.302, + "p50_ms": 0.323, + "p95_ms": 0.409, + "p99_ms": 0.409, + "max_ms": 0.409 + }, + "/detection": { + "count": 16, + "min_ms": 0.044, + "p50_ms": 0.052, + "p95_ms": 0.059, + "p99_ms": 0.059, + "max_ms": 0.059 + }, + "/detection/stats": { + "count": 16, + "min_ms": 0.297, + "p50_ms": 0.32, + "p95_ms": 0.457, + "p99_ms": 0.457, + "max_ms": 0.457 + }, + "/confirm/pending": { + "count": 16, + "min_ms": 0.045, + "p50_ms": 0.049, + "p95_ms": 0.057, + "p99_ms": 0.057, + "max_ms": 0.057 + }, + "/skills": { + "count": 16, + "min_ms": 0.205, + "p50_ms": 0.228, + "p95_ms": 0.315, + "p99_ms": 0.315, + "max_ms": 0.315 + }, + "/setup/state": { + "count": 16, + "min_ms": 0.063, + "p50_ms": 0.068, + "p95_ms": 0.084, + "p99_ms": 0.084, + "max_ms": 0.084 + }, + "/setup/assets": { + "count": 16, + "min_ms": 0.067, + "p50_ms": 0.073, + "p95_ms": 0.086, + "p99_ms": 0.086, + "max_ms": 0.086 + }, + "/mcp/connectors": { + "count": 16, + "min_ms": 0.193, + "p50_ms": 0.214, + "p95_ms": 0.235, + "p99_ms": 0.235, + "max_ms": 0.235 + } + }, + "service_vm": { + "/info/epbench-937920e0-0": { + "count": 4, + "min_ms": 0.435, + "p50_ms": 0.468, + "p95_ms": 0.629, + "p99_ms": 0.629, + "max_ms": 0.629 + }, + "/logs/epbench-937920e0-0": { + "count": 4, + "min_ms": 0.619, + "p50_ms": 0.656, + "p95_ms": 0.745, + "p99_ms": 0.745, + "max_ms": 0.745 + }, + "/history/epbench-937920e0-0": { + "count": 4, + "min_ms": 0.304, + "p50_ms": 0.322, + "p95_ms": 0.371, + "p99_ms": 0.371, + "max_ms": 0.371 + }, + "/history/epbench-937920e0-0/counts": { + "count": 4, + "min_ms": 0.301, + "p50_ms": 0.304, + "p95_ms": 0.343, + "p99_ms": 0.343, + "max_ms": 0.343 + }, + "/history/epbench-937920e0-0/processes": { + "count": 4, + "min_ms": 0.296, + "p50_ms": 0.319, + "p95_ms": 0.35, + "p99_ms": 0.35, + "max_ms": 0.35 + }, + "/history/epbench-937920e0-0/transcript": { + "count": 4, + "min_ms": 0.067, + "p50_ms": 0.07, + "p95_ms": 0.081, + "p99_ms": 0.081, + "max_ms": 0.081 + }, + "/files/epbench-937920e0-0": { + "count": 4, + "min_ms": 2.06, + "p50_ms": 2.247, + "p95_ms": 2.491, + "p99_ms": 2.491, + "max_ms": 2.491 + }, + "/sessions/epbench-937920e0-0/policy-contexts": { + "count": 4, + "min_ms": 0.616, + "p50_ms": 0.67, + "p95_ms": 0.737, + "p99_ms": 0.737, + "max_ms": 0.737 + }, + "/info/epbench-937920e0-1": { + "count": 4, + "min_ms": 0.361, + "p50_ms": 0.372, + "p95_ms": 0.449, + "p99_ms": 0.449, + "max_ms": 0.449 + }, + "/logs/epbench-937920e0-1": { + "count": 4, + "min_ms": 0.588, + "p50_ms": 0.609, + "p95_ms": 0.66, + "p99_ms": 0.66, + "max_ms": 0.66 + }, + "/history/epbench-937920e0-1": { + "count": 4, + "min_ms": 0.334, + "p50_ms": 0.371, + "p95_ms": 0.418, + "p99_ms": 0.418, + "max_ms": 0.418 + }, + "/history/epbench-937920e0-1/counts": { + "count": 4, + "min_ms": 0.278, + "p50_ms": 0.282, + "p95_ms": 0.327, + "p99_ms": 0.327, + "max_ms": 0.327 + }, + "/history/epbench-937920e0-1/processes": { + "count": 4, + "min_ms": 0.313, + "p50_ms": 0.322, + "p95_ms": 0.345, + "p99_ms": 0.345, + "max_ms": 0.345 + }, + "/history/epbench-937920e0-1/transcript": { + "count": 4, + "min_ms": 0.062, + "p50_ms": 0.065, + "p95_ms": 0.074, + "p99_ms": 0.074, + "max_ms": 0.074 + }, + "/files/epbench-937920e0-1": { + "count": 4, + "min_ms": 2.17, + "p50_ms": 2.195, + "p95_ms": 2.28, + "p99_ms": 2.28, + "max_ms": 2.28 + }, + "/sessions/epbench-937920e0-1/policy-contexts": { + "count": 4, + "min_ms": 0.582, + "p50_ms": 0.621, + "p95_ms": 0.648, + "p99_ms": 0.648, + "max_ms": 0.648 + }, + "/info/epbench-937920e0-2": { + "count": 4, + "min_ms": 0.374, + "p50_ms": 0.4, + "p95_ms": 0.434, + "p99_ms": 0.434, + "max_ms": 0.434 + }, + "/logs/epbench-937920e0-2": { + "count": 4, + "min_ms": 0.616, + "p50_ms": 0.647, + "p95_ms": 0.688, + "p99_ms": 0.688, + "max_ms": 0.688 + }, + "/history/epbench-937920e0-2": { + "count": 4, + "min_ms": 0.305, + "p50_ms": 0.318, + "p95_ms": 0.333, + "p99_ms": 0.333, + "max_ms": 0.333 + }, + "/history/epbench-937920e0-2/counts": { + "count": 4, + "min_ms": 0.278, + "p50_ms": 0.284, + "p95_ms": 0.312, + "p99_ms": 0.312, + "max_ms": 0.312 + }, + "/history/epbench-937920e0-2/processes": { + "count": 4, + "min_ms": 0.282, + "p50_ms": 0.282, + "p95_ms": 0.325, + "p99_ms": 0.325, + "max_ms": 0.325 + }, + "/history/epbench-937920e0-2/transcript": { + "count": 4, + "min_ms": 0.066, + "p50_ms": 0.066, + "p95_ms": 0.077, + "p99_ms": 0.077, + "max_ms": 0.077 + }, + "/files/epbench-937920e0-2": { + "count": 4, + "min_ms": 2.188, + "p50_ms": 2.251, + "p95_ms": 2.318, + "p99_ms": 2.318, + "max_ms": 2.318 + }, + "/sessions/epbench-937920e0-2/policy-contexts": { + "count": 4, + "min_ms": 0.599, + "p50_ms": 0.638, + "p95_ms": 0.719, + "p99_ms": 0.719, + "max_ms": 0.719 + }, + "/info/epbench-937920e0-3": { + "count": 4, + "min_ms": 0.378, + "p50_ms": 0.419, + "p95_ms": 0.497, + "p99_ms": 0.497, + "max_ms": 0.497 + }, + "/logs/epbench-937920e0-3": { + "count": 4, + "min_ms": 0.604, + "p50_ms": 0.616, + "p95_ms": 0.69, + "p99_ms": 0.69, + "max_ms": 0.69 + }, + "/history/epbench-937920e0-3": { + "count": 4, + "min_ms": 0.324, + "p50_ms": 0.326, + "p95_ms": 0.374, + "p99_ms": 0.374, + "max_ms": 0.374 + }, + "/history/epbench-937920e0-3/counts": { + "count": 4, + "min_ms": 0.264, + "p50_ms": 0.278, + "p95_ms": 0.348, + "p99_ms": 0.348, + "max_ms": 0.348 + }, + "/history/epbench-937920e0-3/processes": { + "count": 4, + "min_ms": 0.279, + "p50_ms": 0.314, + "p95_ms": 0.331, + "p99_ms": 0.331, + "max_ms": 0.331 + }, + "/history/epbench-937920e0-3/transcript": { + "count": 4, + "min_ms": 0.063, + "p50_ms": 0.067, + "p95_ms": 0.074, + "p99_ms": 0.074, + "max_ms": 0.074 + }, + "/files/epbench-937920e0-3": { + "count": 4, + "min_ms": 2.125, + "p50_ms": 2.19, + "p95_ms": 2.263, + "p99_ms": 2.263, + "max_ms": 2.263 + }, + "/sessions/epbench-937920e0-3/policy-contexts": { + "count": 4, + "min_ms": 0.594, + "p50_ms": 0.636, + "p95_ms": 0.648, + "p99_ms": 0.648, + "max_ms": 0.648 + }, + "/info/epbench-937920e0-4": { + "count": 4, + "min_ms": 0.401, + "p50_ms": 0.42, + "p95_ms": 0.434, + "p99_ms": 0.434, + "max_ms": 0.434 + }, + "/logs/epbench-937920e0-4": { + "count": 4, + "min_ms": 0.571, + "p50_ms": 0.606, + "p95_ms": 0.68, + "p99_ms": 0.68, + "max_ms": 0.68 + }, + "/history/epbench-937920e0-4": { + "count": 4, + "min_ms": 0.314, + "p50_ms": 0.334, + "p95_ms": 0.348, + "p99_ms": 0.348, + "max_ms": 0.348 + }, + "/history/epbench-937920e0-4/counts": { + "count": 4, + "min_ms": 0.263, + "p50_ms": 0.267, + "p95_ms": 0.31, + "p99_ms": 0.31, + "max_ms": 0.31 + }, + "/history/epbench-937920e0-4/processes": { + "count": 4, + "min_ms": 0.272, + "p50_ms": 0.276, + "p95_ms": 0.333, + "p99_ms": 0.333, + "max_ms": 0.333 + }, + "/history/epbench-937920e0-4/transcript": { + "count": 4, + "min_ms": 0.065, + "p50_ms": 0.065, + "p95_ms": 0.078, + "p99_ms": 0.078, + "max_ms": 0.078 + }, + "/files/epbench-937920e0-4": { + "count": 4, + "min_ms": 2.109, + "p50_ms": 2.198, + "p95_ms": 2.323, + "p99_ms": 2.323, + "max_ms": 2.323 + }, + "/sessions/epbench-937920e0-4/policy-contexts": { + "count": 4, + "min_ms": 0.582, + "p50_ms": 0.615, + "p95_ms": 0.767, + "p99_ms": 0.767, + "max_ms": 0.767 + }, + "/info/epbench-937920e0-5": { + "count": 4, + "min_ms": 0.383, + "p50_ms": 0.411, + "p95_ms": 0.414, + "p99_ms": 0.414, + "max_ms": 0.414 + }, + "/logs/epbench-937920e0-5": { + "count": 4, + "min_ms": 0.576, + "p50_ms": 0.61, + "p95_ms": 0.697, + "p99_ms": 0.697, + "max_ms": 0.697 + }, + "/history/epbench-937920e0-5": { + "count": 4, + "min_ms": 0.32, + "p50_ms": 0.337, + "p95_ms": 0.355, + "p99_ms": 0.355, + "max_ms": 0.355 + }, + "/history/epbench-937920e0-5/counts": { + "count": 4, + "min_ms": 0.274, + "p50_ms": 0.28, + "p95_ms": 0.342, + "p99_ms": 0.342, + "max_ms": 0.342 + }, + "/history/epbench-937920e0-5/processes": { + "count": 4, + "min_ms": 0.27, + "p50_ms": 0.304, + "p95_ms": 0.315, + "p99_ms": 0.315, + "max_ms": 0.315 + }, + "/history/epbench-937920e0-5/transcript": { + "count": 4, + "min_ms": 0.07, + "p50_ms": 0.076, + "p95_ms": 0.08, + "p99_ms": 0.08, + "max_ms": 0.08 + }, + "/files/epbench-937920e0-5": { + "count": 4, + "min_ms": 2.072, + "p50_ms": 2.109, + "p95_ms": 2.298, + "p99_ms": 2.298, + "max_ms": 2.298 + }, + "/sessions/epbench-937920e0-5/policy-contexts": { + "count": 4, + "min_ms": 0.59, + "p50_ms": 0.61, + "p95_ms": 0.658, + "p99_ms": 0.658, + "max_ms": 0.658 + }, + "/info/epbench-937920e0-6": { + "count": 4, + "min_ms": 0.367, + "p50_ms": 0.382, + "p95_ms": 0.449, + "p99_ms": 0.449, + "max_ms": 0.449 + }, + "/logs/epbench-937920e0-6": { + "count": 4, + "min_ms": 0.573, + "p50_ms": 0.588, + "p95_ms": 0.685, + "p99_ms": 0.685, + "max_ms": 0.685 + }, + "/history/epbench-937920e0-6": { + "count": 4, + "min_ms": 0.296, + "p50_ms": 0.313, + "p95_ms": 0.316, + "p99_ms": 0.316, + "max_ms": 0.316 + }, + "/history/epbench-937920e0-6/counts": { + "count": 4, + "min_ms": 0.271, + "p50_ms": 0.274, + "p95_ms": 0.283, + "p99_ms": 0.283, + "max_ms": 0.283 + }, + "/history/epbench-937920e0-6/processes": { + "count": 4, + "min_ms": 0.276, + "p50_ms": 0.282, + "p95_ms": 0.294, + "p99_ms": 0.294, + "max_ms": 0.294 + }, + "/history/epbench-937920e0-6/transcript": { + "count": 4, + "min_ms": 0.073, + "p50_ms": 0.077, + "p95_ms": 0.077, + "p99_ms": 0.077, + "max_ms": 0.077 + }, + "/files/epbench-937920e0-6": { + "count": 4, + "min_ms": 2.059, + "p50_ms": 2.139, + "p95_ms": 2.239, + "p99_ms": 2.239, + "max_ms": 2.239 + }, + "/sessions/epbench-937920e0-6/policy-contexts": { + "count": 4, + "min_ms": 0.624, + "p50_ms": 0.629, + "p95_ms": 0.76, + "p99_ms": 0.76, + "max_ms": 0.76 + }, + "/info/epbench-937920e0-7": { + "count": 4, + "min_ms": 0.408, + "p50_ms": 0.411, + "p95_ms": 0.469, + "p99_ms": 0.469, + "max_ms": 0.469 + }, + "/logs/epbench-937920e0-7": { + "count": 4, + "min_ms": 0.558, + "p50_ms": 0.586, + "p95_ms": 0.614, + "p99_ms": 0.614, + "max_ms": 0.614 + }, + "/history/epbench-937920e0-7": { + "count": 4, + "min_ms": 0.304, + "p50_ms": 0.305, + "p95_ms": 0.339, + "p99_ms": 0.339, + "max_ms": 0.339 + }, + "/history/epbench-937920e0-7/counts": { + "count": 4, + "min_ms": 0.27, + "p50_ms": 0.287, + "p95_ms": 0.306, + "p99_ms": 0.306, + "max_ms": 0.306 + }, + "/history/epbench-937920e0-7/processes": { + "count": 4, + "min_ms": 0.262, + "p50_ms": 0.285, + "p95_ms": 0.291, + "p99_ms": 0.291, + "max_ms": 0.291 + }, + "/history/epbench-937920e0-7/transcript": { + "count": 4, + "min_ms": 0.069, + "p50_ms": 0.07, + "p95_ms": 0.077, + "p99_ms": 0.077, + "max_ms": 0.077 + }, + "/files/epbench-937920e0-7": { + "count": 4, + "min_ms": 2.024, + "p50_ms": 2.091, + "p95_ms": 2.254, + "p99_ms": 2.254, + "max_ms": 2.254 + }, + "/sessions/epbench-937920e0-7/policy-contexts": { + "count": 4, + "min_ms": 0.582, + "p50_ms": 0.622, + "p95_ms": 0.685, + "p99_ms": 0.685, + "max_ms": 0.685 + } + }, + "gateway": { + "/health": { + "count": 32, + "min_ms": 0.12, + "p50_ms": 0.133, + "p95_ms": 0.187, + "p99_ms": 0.221, + "max_ms": 0.221 + }, + "/token": { + "count": 32, + "min_ms": 0.109, + "p50_ms": 0.121, + "p95_ms": 0.164, + "p99_ms": 0.166, + "max_ms": 0.166 + }, + "/status": { + "count": 32, + "min_ms": 0.186, + "p50_ms": 0.198, + "p95_ms": 0.223, + "p99_ms": 0.269, + "max_ms": 0.269 + } + } + }, + "schema": "capsem.benchmark-artifact.v1", + "project_version": "1.2.1780103109", + "arch": "arm64", + "recorded_at": 1780103291.44931, + "recorded_at_utc": "2026-05-30T01:08:11.449313+00:00", + "command": "uv run pytest tests/capsem-serial/test_endpoint_latency_benchmark.py -xvs", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "git": { + "commit": "a21e269c34f5eb9993f6198b635e65e00bfc42f0", + "dirty": true, + "source_dirty": true, + "dirty_paths": [ + "CHANGELOG.md", + "Cargo.toml", + "crates/capsem-app/tauri.conf.json", + "crates/capsem-core/src/session/index.rs", + "crates/capsem-logger/src/reader.rs", + "crates/capsem-process/src/ipc.rs", + "crates/capsem-process/src/ipc/tests.rs", + "crates/capsem-process/src/main.rs", + "crates/capsem-service/src/main.rs", + "crates/capsem-service/src/tests.rs", + "crates/capsem-tui/src/app.rs", + "crates/capsem-tui/src/tests.rs", + "pyproject.toml", + "skills/dev-benchmark/SKILL.md", + "sprints/tui-control/MASTER.md", + "sprints/tui-control/tracker.md", + "uv.lock", + "benchmarks/endpoint-latency/data_1.2.1779673506_arm64.json", + "tests/capsem-serial/test_endpoint_latency_benchmark.py" + ] + } +} \ No newline at end of file diff --git a/crates/capsem-app/tauri.conf.json b/crates/capsem-app/tauri.conf.json index 5ee807f9..92ea0083 100644 --- a/crates/capsem-app/tauri.conf.json +++ b/crates/capsem-app/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json", "productName": "Capsem", - "version": "1.2.1779673506", + "version": "1.2.1780103109", "identifier": "com.capsem.capsem", "build": { "beforeDevCommand": "pnpm dev", diff --git a/crates/capsem-core/src/session/index.rs b/crates/capsem-core/src/session/index.rs index 86983a73..d0fe88f1 100644 --- a/crates/capsem-core/src/session/index.rs +++ b/crates/capsem-core/src/session/index.rs @@ -1,6 +1,6 @@ use std::path::Path; -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, OpenFlags}; use super::types::*; @@ -91,6 +91,19 @@ impl SessionIndex { Ok(Self { conn }) } + /// Open an existing session index for read-only hot paths. + /// + /// This intentionally skips schema creation/migration. Callers that own + /// writes should use `open`; read endpoints should not pay migration cost + /// on every request. + pub fn open_readonly(path: &Path) -> rusqlite::Result { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + Ok(Self { conn }) + } + /// Open an in-memory database (for testing). pub fn open_in_memory() -> rusqlite::Result { let conn = Connection::open_in_memory()?; diff --git a/crates/capsem-logger/src/reader.rs b/crates/capsem-logger/src/reader.rs index 618abcdc..d30d732f 100644 --- a/crates/capsem-logger/src/reader.rs +++ b/crates/capsem-logger/src/reader.rs @@ -1,8 +1,6 @@ use std::collections::BTreeMap; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::SystemTime; +use std::time::{Duration, Instant, SystemTime}; use rusqlite::{params, Connection, OpenFlags, OptionalExtension, Row}; use serde::Serialize; @@ -315,39 +313,7 @@ impl DbReader { validate_select_only(sql)?; const MAX_ROWS: usize = 10_000; - const TIMEOUT_MS: u64 = 5_000; - const POLL_MS: u64 = 100; - - // Set up interrupt timer. - let interrupt_handle = self.conn.get_interrupt_handle(); - let done = Arc::new(AtomicBool::new(false)); - let done_clone = Arc::clone(&done); - let timer = std::thread::spawn(move || { - let polls = TIMEOUT_MS / POLL_MS; - for _ in 0..polls { - std::thread::sleep(std::time::Duration::from_millis(POLL_MS)); - if done_clone.load(Ordering::Relaxed) { - return; - } - } - if !done_clone.load(Ordering::Relaxed) { - interrupt_handle.interrupt(); - } - }); - - let result = self.query_raw_inner(sql, MAX_ROWS); - - // Signal timer to stop and wait for it. - done.store(true, Ordering::Relaxed); - let _ = timer.join(); - - result.map_err(|e| { - if e.contains("interrupted") { - "query timed out after 5 seconds".to_string() - } else { - e - } - }) + self.with_query_timeout(|| self.query_raw_inner(sql, MAX_ROWS)) } /// Execute an arbitrary read-only SQL query with bind parameters and return JSON. @@ -360,29 +326,23 @@ impl DbReader { validate_select_only(sql)?; const MAX_ROWS: usize = 10_000; + self.with_query_timeout(|| self.query_raw_params_inner(sql, params, MAX_ROWS)) + } + + fn with_query_timeout(&self, query: F) -> Result + where + F: FnOnce() -> Result, + { const TIMEOUT_MS: u64 = 5_000; - const POLL_MS: u64 = 100; - - let interrupt_handle = self.conn.get_interrupt_handle(); - let done = Arc::new(AtomicBool::new(false)); - let done_clone = Arc::clone(&done); - let timer = std::thread::spawn(move || { - let polls = TIMEOUT_MS / POLL_MS; - for _ in 0..polls { - std::thread::sleep(std::time::Duration::from_millis(POLL_MS)); - if done_clone.load(Ordering::Relaxed) { - return; - } - } - if !done_clone.load(Ordering::Relaxed) { - interrupt_handle.interrupt(); - } - }); + const PROGRESS_OPS: i32 = 10_000; + + let deadline = Instant::now() + Duration::from_millis(TIMEOUT_MS); + self.conn + .progress_handler(PROGRESS_OPS, Some(move || Instant::now() >= deadline)); - let result = self.query_raw_params_inner(sql, params, MAX_ROWS); + let result = query(); - done.store(true, Ordering::Relaxed); - let _ = timer.join(); + self.conn.progress_handler(0, None:: bool>); result.map_err(|e| { if e.contains("interrupted") { @@ -1561,6 +1521,19 @@ mod tests { assert_eq!(parsed["rows"][1][0], "evil.com"); } + #[test] + fn query_raw_fast_path_does_not_wait_for_interrupt_timer() { + let reader = setup_reader_with_data(); + let started = std::time::Instant::now(); + for _ in 0..3 { + reader.query_raw("SELECT 1 AS one").unwrap(); + } + assert!( + started.elapsed() < std::time::Duration::from_millis(80), + "fast SELECTs should not pay the old 100ms interrupt timer floor" + ); + } + #[test] fn query_raw_with_params_binds_values() { let reader = setup_reader_with_data(); diff --git a/crates/capsem-process/src/ipc.rs b/crates/capsem-process/src/ipc.rs index 3412721d..ed6d5bee 100644 --- a/crates/capsem-process/src/ipc.rs +++ b/crates/capsem-process/src/ipc.rs @@ -2,7 +2,7 @@ use anyhow::Result; use capsem_proto::ipc::{ProcessToService, ServiceToProcess}; use capsem_proto::metrics::VmMetricsSnapshot; use nix::libc; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -43,7 +43,6 @@ const READY_SHUTDOWN_GRACE: Duration = Duration::from_secs(2); pub(crate) struct ResourceMetricsContext { pub(crate) configured_vcpus: u32, pub(crate) configured_ram_mb: u64, - pub(crate) session_dir: PathBuf, } fn shutdown_grace_period(vm_ready: bool) -> Duration { @@ -725,11 +724,6 @@ fn metrics_snapshot( snapshot.resources.host_process_rss_bytes = Some(proc_stats.rss_bytes); snapshot.resources.host_cpu_time_micros = Some(proc_stats.cpu_time_micros); } - snapshot.resources.session_disk_bytes = dir_size_bytes(&resources.session_dir).ok(); - snapshot.resources.workspace_disk_bytes = - dir_size_bytes(&resources.session_dir.join("guest").join("workspace")).ok(); - snapshot.resources.rootfs_overlay_bytes = - dir_size_bytes(&resources.session_dir.join("guest").join("system")).ok(); snapshot } @@ -770,35 +764,6 @@ fn parse_proc_stat(stat: &str) -> Option { }) } -fn dir_size_bytes(path: &Path) -> std::io::Result { - let metadata = match std::fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), - Err(e) => return Err(e), - }; - if metadata.is_file() { - return Ok(metadata.len()); - } - if !metadata.is_dir() { - return Ok(0); - } - - let mut total = 0u64; - for entry in std::fs::read_dir(path)? { - let entry = entry?; - let file_type = entry.file_type()?; - if file_type.is_symlink() { - continue; - } - if file_type.is_dir() { - total = total.saturating_add(dir_size_bytes(&entry.path())?); - } else if file_type.is_file() { - total = total.saturating_add(entry.metadata()?.len()); - } - } - Ok(total) -} - #[cfg(test)] #[derive(Debug, PartialEq)] enum IpcAction { diff --git a/crates/capsem-process/src/ipc/tests.rs b/crates/capsem-process/src/ipc/tests.rs index 450b77e4..7dd6d154 100644 --- a/crates/capsem-process/src/ipc/tests.rs +++ b/crates/capsem-process/src/ipc/tests.rs @@ -108,23 +108,9 @@ fn classify_drain_runtime_rule_matches() { #[test] fn metrics_snapshot_is_process_owned_and_versioned() { let writer = capsem_logger::DbWriter::open_in_memory(16).unwrap(); - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join("guest").join("workspace")).unwrap(); - std::fs::create_dir_all(dir.path().join("guest").join("system")).unwrap(); - std::fs::write( - dir.path().join("guest").join("workspace").join("work.txt"), - b"work", - ) - .unwrap(); - std::fs::write( - dir.path().join("guest").join("system").join("rootfs.img"), - b"overlay", - ) - .unwrap(); let resources = ResourceMetricsContext { configured_vcpus: 4, configured_ram_mb: 8192, - session_dir: dir.path().to_path_buf(), }; let snapshot = metrics_snapshot(&writer, "vm-s07", &resources); @@ -148,9 +134,9 @@ fn metrics_snapshot_is_process_owned_and_versioned() { assert!(snapshot.resources.host_cpu_time_micros.is_some()); #[cfg(not(target_os = "linux"))] assert!(snapshot.resources.host_cpu_time_micros.is_none()); - assert_eq!(snapshot.resources.workspace_disk_bytes, Some(4)); - assert_eq!(snapshot.resources.rootfs_overlay_bytes, Some(7)); - assert_eq!(snapshot.resources.session_disk_bytes, Some(11)); + assert_eq!(snapshot.resources.workspace_disk_bytes, None); + assert_eq!(snapshot.resources.rootfs_overlay_bytes, None); + assert_eq!(snapshot.resources.session_disk_bytes, None); assert!(snapshot.captured_at_unix_ms > 0); } diff --git a/crates/capsem-process/src/main.rs b/crates/capsem-process/src/main.rs index 041e9cb7..31430601 100644 --- a/crates/capsem-process/src/main.rs +++ b/crates/capsem-process/src/main.rs @@ -760,7 +760,6 @@ async fn run_async_main_loop( let resource_metrics = ipc::ResourceMetricsContext { configured_vcpus: args.cpus, configured_ram_mb: args.ram_mb, - session_dir: session_dir.clone(), }; tokio::spawn(async move { diff --git a/crates/capsem-service/src/main.rs b/crates/capsem-service/src/main.rs index bfdd1b3a..6ffc76a8 100644 --- a/crates/capsem-service/src/main.rs +++ b/crates/capsem-service/src/main.rs @@ -2972,9 +2972,6 @@ async fn handle_list(State(state): State>) -> Json>, ) -> Result, AppError> { let db_path = state.main_db_path(); - let index = capsem_core::session::SessionIndex::open(&db_path).map_err(|e| { + if !db_path.exists() { + return Ok(Json(empty_stats_response())); + } + let index = capsem_core::session::SessionIndex::open_readonly(&db_path).map_err(|e| { AppError( StatusCode::INTERNAL_SERVER_ERROR, format!("failed to open main.db: {e}"), @@ -3265,6 +3265,27 @@ async fn handle_stats( })) } +fn empty_stats_response() -> StatsResponse { + StatsResponse { + global: capsem_core::session::GlobalStats { + total_sessions: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_estimated_cost: 0.0, + total_tool_calls: 0, + total_mcp_calls: 0, + total_file_events: 0, + total_requests: 0, + total_allowed: 0, + total_denied: 0, + }, + sessions: Vec::new(), + top_providers: Vec::new(), + top_tools: Vec::new(), + top_mcp_tools: Vec::new(), + } +} + async fn handle_logs( State(state): State>, Path(id): Path, @@ -3334,6 +3355,9 @@ fn read_security_logs_from_session_db(session_dir: &FsPath) -> Result Result Result { + let json_str = reader + .query_raw("SELECT 1 FROM security_events LIMIT 1") + .map_err(|error| { + AppError( + StatusCode::INTERNAL_SERVER_ERROR, + format!("query session security event presence: {error}"), + ) + })?; + let value: serde_json::Value = serde_json::from_str(&json_str).map_err(|error| { + AppError( + StatusCode::INTERNAL_SERVER_ERROR, + format!("parse session security event presence: {error}"), + ) + })?; + Ok(value + .get("rows") + .and_then(|rows| rows.as_array()) + .map(|rows| !rows.is_empty()) + .unwrap_or(false)) +} + fn security_log_cell( row: &serde_json::Value, index: usize, @@ -8003,6 +8049,7 @@ fn security_events_query_rows( ON (ame.mcp_call_id = se.mcp_call_id OR ame.mcp_call_id = m.request_id) AND se.event_family = 'mcp' + GROUP BY se.id ORDER BY se.timestamp_unix_ms ASC, se.id ASC LIMIT 10000", ) @@ -9010,6 +9057,14 @@ fn session_policy_context_export_json( session_id: &str, reader: &capsem_logger::DbReader, ) -> Result { + if !session_has_security_events(reader)? { + return Ok(json!({ + "schema": "capsem.policy-context-export.v1", + "session_id": session_id, + "fixture_count": 0, + "fixtures": [], + })); + } let events = session_backtest_events(session_id, reader)?; let fixtures = events .iter() diff --git a/crates/capsem-service/src/tests.rs b/crates/capsem-service/src/tests.rs index 6efaea3c..6d9047b7 100644 --- a/crates/capsem-service/src/tests.rs +++ b/crates/capsem-service/src/tests.rs @@ -8583,6 +8583,38 @@ async fn handle_session_detection_hunt_reads_hand_built_security_db_corpus() { "coding" ); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + insert_hunt_security_event_fixture( + &conn, + "evt-duplicate-file", + "trace-duplicate-file", + 1_700_000_000_010, + "file", + "file.activity", + "file", + ); + conn.execute( + "INSERT INTO fs_events ( + timestamp, action, path, size, trace_id + ) VALUES + ('2026-05-21T10:00:00Z', 'read', '/workspace/a.txt', 1, 'trace-duplicate-file'), + ('2026-05-21T10:00:00Z', 'read', '/workspace/b.txt', 1, 'trace-duplicate-file')", + [], + ) + .unwrap(); + } + let reader = capsem_logger::DbReader::open(&db_path).unwrap(); + let reconstructed = session_backtest_events(vm_id, &reader).unwrap(); + let duplicate_refs = reconstructed + .iter() + .filter(|event| event.event.common.event_id == "evt-duplicate-file") + .count(); + assert_eq!( + duplicate_refs, 1, + "one security event with multiple detail rows must export once" + ); + let Json(result) = handle_session_detection_hunt( Path(vm_id.into()), State(state), diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index c4b247b1..fd3158fd 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -1,7 +1,6 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use std::time::Duration; -use crate::model::{AppState, ServiceStatus, SessionLifecycle}; +use crate::model::{AppState, SessionLifecycle}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum AppAction { @@ -89,12 +88,6 @@ impl App { pub fn replace_state(&mut self, mut state: AppState) { state.service.control_message = self.state.service.control_message.clone(); - if matches!(self.state.service.status, ServiceStatus::Online) - && matches!(state.service.status, ServiceStatus::Online) - { - state.service.latency = - stable_local_latency(self.state.service.latency, state.service.latency); - } let previous_active_id = self.state.active_session_id.clone(); if state .sessions @@ -267,23 +260,6 @@ impl App { } } -fn stable_local_latency(previous: Duration, next: Duration) -> Duration { - const LOCAL_JITTER_CEILING: Duration = Duration::from_millis(100); - if previous == Duration::ZERO - || previous >= LOCAL_JITTER_CEILING - || next >= LOCAL_JITTER_CEILING - { - return next; - } - let previous_micros = previous.as_micros(); - let next_micros = next.as_micros(); - let smoothed = previous_micros - .saturating_mul(3) - .saturating_add(next_micros) - / 4; - Duration::from_micros(smoothed.min(u64::MAX as u128) as u64) -} - fn is_exit_key(key: KeyEvent) -> bool { matches!( (key.code, key.modifiers), diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index acc4218f..bb739e41 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -111,28 +111,19 @@ fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { } #[test] -fn replace_state_smooths_local_service_latency_jitter() { +fn replace_state_preserves_fresh_service_latency_measurement() { let mut initial = fixture_state(); initial.service.latency = std::time::Duration::from_millis(1); let mut app = App::new(initial); - let mut cache_refresh = fixture_state(); - cache_refresh.service.latency = std::time::Duration::from_millis(7); - app.replace_state(cache_refresh); - - assert_eq!( - app.state().service.latency, - std::time::Duration::from_micros(2_500) - ); - - let mut real_slow = fixture_state(); - real_slow.service.latency = std::time::Duration::from_millis(150); - app.replace_state(real_slow); + let mut refreshed = fixture_state(); + refreshed.service.latency = std::time::Duration::from_millis(7); + app.replace_state(refreshed); assert_eq!( app.state().service.latency, - std::time::Duration::from_millis(150), - "real degraded latency should not be hidden by local jitter smoothing" + std::time::Duration::from_millis(7), + "TUI should report the measured latency; latency stability belongs in the service hot path" ); } diff --git a/pyproject.toml b/pyproject.toml index e63682e8..e3923415 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "capsem" -version = "1.2.1779673506" +version = "1.2.1780103109" requires-python = ">=3.11" dependencies = [ "pydantic>=2.0", diff --git a/skills/dev-benchmark/SKILL.md b/skills/dev-benchmark/SKILL.md index 3b2522ac..e40e1d21 100644 --- a/skills/dev-benchmark/SKILL.md +++ b/skills/dev-benchmark/SKILL.md @@ -159,6 +159,42 @@ uv run pytest tests/capsem-serial/test_lifecycle_benchmark.py -xvs Every operation must complete in under 1.2 seconds. The test runs 3 cycles and asserts each individual operation stays under the gate. +## Host-side endpoint latency benchmark + +Profiles service and gateway read endpoints with eight live temporary VMs. This +is the TUI/control-plane hot-path gate and intentionally uses raw HTTP clients +instead of curl helpers so process startup does not pollute endpoint timing. + +```bash +uv run pytest tests/capsem-serial/test_endpoint_latency_benchmark.py -xvs +``` + +**Location:** `tests/capsem-serial/test_endpoint_latency_benchmark.py` + +### Endpoint groups + +| Group | What it covers | Default gate | +|-------|----------------|--------------| +| service_global | `/version`, `/list`, `/stats`, settings, profile, rules, enforcement, detection, setup, skills, MCP connector reads | p95 <= 3ms, max <= 10ms | +| service_vm | `/info/{id}`, logs, history, file listing, session policy contexts across all 8 VMs | p95 <= 12ms, max <= 35ms | +| gateway | `/health`, `/token`, `/status` over persistent TCP | p95 <= 2ms, max <= 8ms | + +### Tunables + +- `CAPSEM_ENDPOINT_BENCH_VM_COUNT`: number of live VMs (default: 8) +- `CAPSEM_ENDPOINT_BENCH_GLOBAL_RUNS`: iterations per global endpoint (default: 16) +- `CAPSEM_ENDPOINT_BENCH_VM_RUNS`: iterations per per-VM endpoint (default: 4) +- `CAPSEM_ENDPOINT_BENCH_GATEWAY_RUNS`: iterations per gateway endpoint (default: 32) +- `CAPSEM_ENDPOINT_BENCH_{GLOBAL,VM,GATEWAY}_P95_MS`: p95 gates +- `CAPSEM_ENDPOINT_BENCH_{GLOBAL,VM,GATEWAY}_MAX_MS`: max gates + +### When to run + +- After changes to `/list`, `/status`, `/info`, history, files, settings, + profile, rule, detection, enforcement, setup, skills, or gateway proxy paths +- After adding TUI polling, dashboard, tray, or gateway aggregation behavior +- Before release when claiming local control-plane responsiveness + ## Host-side fork benchmark Profiles fork (image creation) and boot-from-image. Same test file, separate test function. @@ -282,6 +318,7 @@ projection. - In-VM availability: `test_utilities.py::test_utility_available[capsem-bench]` - Host-side lifecycle: `uv run pytest tests/capsem-serial/test_lifecycle_benchmark.py::test_lifecycle_benchmark -xvs` - Host-side fork: `uv run pytest tests/capsem-serial/test_lifecycle_benchmark.py::test_fork_benchmark -xvs` +- Host-side endpoint latency: `uv run pytest tests/capsem-serial/test_endpoint_latency_benchmark.py -xvs` - Host-side Security Engine: `uv run pytest tests/capsem-serial/test_security_engine_benchmark.py -xvs` - Both host-side: `uv run pytest tests/capsem-serial/test_lifecycle_benchmark.py -xvs` - Full run: `just benchmark` (or alias `just bench`) or `just test` @@ -297,6 +334,7 @@ that should not dirty the checkout: benchmarks/ fork/data_1.2.3_x86_64_linux-rc1.json # Fork speed, image size, data survival lifecycle/data_1.2.3_x86_64_linux-rc1.json # Provision, exec-ready, exec, delete + endpoint-latency/data_*.json # Service/gateway read latency across 8 live VMs security-engine/data_*.json # CEL microbench and VM-originated enforcement ``` diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 864ccadd..8d6830ae 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -115,6 +115,11 @@ attention markers. - Kept richer missing state explicit for future API work: waiting-for-input, terminal bell, per-session repo/path metadata, security/enforcement/detection totals, and event cursor semantics are not invented by the TUI. +- Reproduced and fixed the local latency stack under an 8-live-VM endpoint + gate: `/list` stays in-memory, process metrics snapshots no longer scan + session directories, raw session DB queries no longer pay a 100ms watchdog + floor, `/stats` has an empty/read-only fast path, and policy-context exports + dedupe by security event before fixture projection. ## Testing Gate @@ -127,4 +132,8 @@ attention markers. gateway terminal WebSocket shell output from `tui-proof-a`. - Telemetry: mapped from current counters; event stream/cursor semantics remain open. -- Performance: frame/render timing deferred until interactive loop exists. +- Performance: 8-live-VM endpoint benchmark is active in the serial benchmark + gate. Latest release-binary arm64 proof has `/list` p95 0.335ms, `/stats` + p95 0.798ms, slowest per-VM read `/files` p95 2.491ms, and gateway + `/status` p95 0.223ms. Concurrent boot pressure remains a separate follow-up + because endpoint speed should not depend on parallel provisioning setup. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index fe4b2001..5393a5be 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -127,21 +127,38 @@ per-connection helper. - Live fd stress after install: 150 service `/list` refreshes across `tui-proof-a` and `tui-proof-b` kept process fd counts flat at 39 and 40. -- Local latency diagnosis: gateway `/status` is stable at roughly 4-8ms when - refreshing the two proof VMs. The visible `0/1ms` versus `7ms` jitter came - from the TUI displaying raw cache-hit/cache-refresh phase and paying token - bootstrap on every refresh. +- Local latency diagnosis: the original two-VM 4-8ms reading was real service + work, not a UI display problem. `/list` was still calling per-VM live metrics, + `capsem-process` metrics snapshots recursively scanned session directories, + and raw session DB queries paid a fixed 100ms watchdog-thread floor. - TUI latency fix: gateway refreshes now reuse the HTTP client and cached - gateway token, and interactive state replacement smooths sub-100ms local - latency jitter without hiding real slow responses. + gateway token while preserving the freshly measured latency value. The + service hot paths now keep `/list` in-memory, keep metrics snapshots + process-owned, use SQLite progress handlers for raw query timeouts, skip + `/stats` schema creation on read, and dedupe policy-context exports by + security event. +- Endpoint latency gate: 8 live temporary VMs now cover global service reads, + per-VM info/logs/history/files/policy-context reads, and gateway + health/token/status reads. Latest release-binary arm64 run: `/list` p95 + 0.335ms, `/stats` p95 0.798ms, slowest per-VM endpoint `/files` p95 + 2.491ms, gateway `/status` p95 0.223ms. +- Boot pressure follow-up: an early 4-way parallel benchmark setup run hit one + `wait_exec_ready` miss before latency measurement. Sequentially provisioning + 8 live VMs is stable, so endpoint latency remains gated separately from a + future concurrent-boot pressure test. ## Coverage Ledger - Unit/contract: `cargo test -p capsem-tui` (25 tests). - TUI latency/provider: `cargo test -p capsem-tui` (25 tests), including - token reuse and local latency smoothing coverage. + token reuse and raw local latency preservation coverage. - Process IPC: `cargo test -p capsem-process` (120 tests), including `connection_teardown_aborts_writer_and_lifecycle_tasks`. +- Service/core/logger hot paths: `cargo test -p capsem-service`, + `cargo test -p capsem-core session`, and `cargo test -p capsem-logger`. +- Endpoint benchmark: `CAPSEM_ASSETS_DIR="$HOME/.capsem/assets" uv run python + -m pytest tests/capsem-serial/test_endpoint_latency_benchmark.py -xvs + --tb=short`. - Formatting: `cargo fmt -p capsem-tui -- --check`. - Process formatting: `cargo fmt -p capsem-process -- --check`. - Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`; @@ -166,4 +183,6 @@ counts flat through repeated service metrics polling. - Telemetry: current gateway `/status` counters mapped; event-stream semantics still open. -- Performance: not measured yet. +- Performance: 8-live-VM endpoint gate passes with `/list` sub-ms, `/stats` + under 1ms p95 on release binaries, all per-VM reads under the 12ms p95 gate, + and gateway `/status` around 0.22ms p95. diff --git a/tests/capsem-serial/test_endpoint_latency_benchmark.py b/tests/capsem-serial/test_endpoint_latency_benchmark.py new file mode 100644 index 00000000..88db01e2 --- /dev/null +++ b/tests/capsem-serial/test_endpoint_latency_benchmark.py @@ -0,0 +1,302 @@ +"""Host-side endpoint latency benchmark for the service and gateway. + +The TUI depends on these read paths feeling instant while multiple VMs are +alive. This benchmark keeps the gate focused on endpoint latency, so it uses +raw persistent/fresh HTTP clients instead of the curl-based correctness helpers. +""" + +import http.client +import json +import math +import os +import re +import socket +import time +import uuid +from pathlib import Path + +import pytest + +from helpers.benchmark_artifacts import ( + benchmark_arch, + benchmark_output_path, + enrich_benchmark_artifact, +) +from helpers.constants import DEFAULT_CPUS, DEFAULT_RAM_MB, EXEC_READY_TIMEOUT +from helpers.gateway import GatewayInstance +from helpers.service import ServiceInstance, wait_exec_ready + +pytestmark = [pytest.mark.serial, pytest.mark.benchmark] + +PROJECT_ROOT = Path(__file__).parent.parent.parent +VM_COUNT = int(os.environ.get("CAPSEM_ENDPOINT_BENCH_VM_COUNT", "8")) +GLOBAL_ITERATIONS = int(os.environ.get("CAPSEM_ENDPOINT_BENCH_GLOBAL_RUNS", "16")) +VM_ITERATIONS = int(os.environ.get("CAPSEM_ENDPOINT_BENCH_VM_RUNS", "4")) +GATEWAY_ITERATIONS = int(os.environ.get("CAPSEM_ENDPOINT_BENCH_GATEWAY_RUNS", "32")) + +GLOBAL_GATE_P95_MS = float(os.environ.get("CAPSEM_ENDPOINT_BENCH_GLOBAL_P95_MS", "3.0")) +GLOBAL_GATE_MAX_MS = float(os.environ.get("CAPSEM_ENDPOINT_BENCH_GLOBAL_MAX_MS", "10.0")) +VM_GATE_P95_MS = float(os.environ.get("CAPSEM_ENDPOINT_BENCH_VM_P95_MS", "12.0")) +VM_GATE_MAX_MS = float(os.environ.get("CAPSEM_ENDPOINT_BENCH_VM_MAX_MS", "35.0")) +GATEWAY_GATE_P95_MS = float(os.environ.get("CAPSEM_ENDPOINT_BENCH_GATEWAY_P95_MS", "2.0")) +GATEWAY_GATE_MAX_MS = float(os.environ.get("CAPSEM_ENDPOINT_BENCH_GATEWAY_MAX_MS", "8.0")) + +GLOBAL_ENDPOINTS = ( + "/version", + "/list", + "/stats", + "/settings", + "/settings/presets", + "/profiles", + "/profiles/catalog", + "/rules", + "/enforcement", + "/enforcement/stats", + "/detection", + "/detection/stats", + "/confirm/pending", + "/skills", + "/setup/state", + "/setup/assets", + "/mcp/connectors", +) + +VM_ENDPOINTS = ( + "/info/{id}", + "/logs/{id}", + "/history/{id}", + "/history/{id}/counts", + "/history/{id}/processes", + "/history/{id}/transcript", + "/files/{id}", + "/sessions/{id}/policy-contexts", +) + +GATEWAY_ENDPOINTS = ( + ("/health", False), + ("/token", False), + ("/status", True), +) + + +def _project_version(): + cargo = PROJECT_ROOT / "Cargo.toml" + match = re.search(r'^version\s*=\s*"([^"]+)"', cargo.read_text(), re.MULTILINE) + return match.group(1) if match else "unknown" + + +def _save_benchmark(data): + version = _project_version() + arch = benchmark_arch() + out_path = benchmark_output_path(PROJECT_ROOT, "endpoint-latency", version, arch) + out_path.parent.mkdir(parents=True, exist_ok=True) + data = enrich_benchmark_artifact( + data, + project_root=PROJECT_ROOT, + project_version=version, + arch=arch, + command="uv run pytest tests/capsem-serial/test_endpoint_latency_benchmark.py -xvs", + ) + out_path.write_text(json.dumps(data, indent=2)) + print(f"Endpoint latency benchmark saved to {out_path}") + + +def _uds_get(socket_path, path): + started = time.perf_counter() + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(10) + sock.connect(str(socket_path)) + request = ( + f"GET {path} HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: close\r\n" + "\r\n" + ).encode() + sock.sendall(request) + chunks = [] + while True: + chunk = sock.recv(65536) + if not chunk: + break + chunks.append(chunk) + elapsed_ms = (time.perf_counter() - started) * 1000 + raw = b"".join(chunks) + status_line = raw.split(b"\r\n", 1)[0].decode("ascii", errors="replace") + parts = status_line.split() + if len(parts) < 2 or not parts[1].isdigit(): + raise AssertionError(f"invalid HTTP response for {path}: {status_line!r}") + status = int(parts[1]) + return status, elapsed_ms + + +def _gateway_get(connection, token, path, use_auth): + headers = {"Connection": "keep-alive"} + if use_auth: + headers["Authorization"] = f"Bearer {token}" + started = time.perf_counter() + connection.request("GET", path, headers=headers) + response = connection.getresponse() + response.read() + elapsed_ms = (time.perf_counter() - started) * 1000 + return response.status, elapsed_ms + + +def _percentile(values, percentile): + ordered = sorted(values) + if not ordered: + return 0.0 + index = math.ceil((percentile / 100) * len(ordered)) - 1 + return ordered[max(0, min(index, len(ordered) - 1))] + + +def _summary(values): + ordered = sorted(values) + return { + "count": len(ordered), + "min_ms": round(ordered[0], 3), + "p50_ms": round(_percentile(ordered, 50), 3), + "p95_ms": round(_percentile(ordered, 95), 3), + "p99_ms": round(_percentile(ordered, 99), 3), + "max_ms": round(ordered[-1], 3), + } + + +def _measure_service_group(socket_path, endpoints, iterations): + results = {} + for endpoint in endpoints: + status, _ = _uds_get(socket_path, endpoint) + assert 200 <= status < 300, f"{endpoint} warmup returned HTTP {status}" + values = [] + for _ in range(iterations): + status, elapsed_ms = _uds_get(socket_path, endpoint) + assert 200 <= status < 300, f"{endpoint} returned HTTP {status}" + values.append(elapsed_ms) + results[endpoint] = _summary(values) + return results + + +def _measure_gateway(gateway): + results = {} + conn = http.client.HTTPConnection("127.0.0.1", gateway.port, timeout=10) + try: + for endpoint, use_auth in GATEWAY_ENDPOINTS: + status, _ = _gateway_get(conn, gateway.token, endpoint, use_auth) + assert 200 <= status < 300, f"gateway {endpoint} warmup returned HTTP {status}" + values = [] + for _ in range(GATEWAY_ITERATIONS): + status, elapsed_ms = _gateway_get(conn, gateway.token, endpoint, use_auth) + assert 200 <= status < 300, f"gateway {endpoint} returned HTTP {status}" + values.append(elapsed_ms) + results[endpoint] = _summary(values) + finally: + conn.close() + return results + + +def _provision_vms(client, names): + for name in names: + client.post( + "/provision", + { + "name": name, + "persistent": False, + "ram_mb": DEFAULT_RAM_MB, + "cpus": DEFAULT_CPUS, + }, + timeout=90, + ) + assert wait_exec_ready(client, name, timeout=EXEC_READY_TIMEOUT), f"{name} not exec-ready" + + +def _check_gates(results): + failures = [] + gates = { + "service_global": (GLOBAL_GATE_P95_MS, GLOBAL_GATE_MAX_MS), + "service_vm": (VM_GATE_P95_MS, VM_GATE_MAX_MS), + "gateway": (GATEWAY_GATE_P95_MS, GATEWAY_GATE_MAX_MS), + } + for group, endpoints in results["groups"].items(): + p95_gate, max_gate = gates[group] + for endpoint, stats in endpoints.items(): + if stats["p95_ms"] > p95_gate or stats["max_ms"] > max_gate: + failures.append( + f"{group} {endpoint}: p95={stats['p95_ms']}ms" + f" max={stats['max_ms']}ms gates p95<={p95_gate}ms max<={max_gate}ms" + ) + assert not failures, "endpoint latency gate failed:\n" + "\n".join(failures) + + +def test_endpoint_latency_benchmark_8_live_vms(): + suffix = uuid.uuid4().hex[:8] + vm_names = [f"epbench-{suffix}-{i}" for i in range(VM_COUNT)] + svc = ServiceInstance() + svc.start() + client = svc.client() + gateway = None + try: + _provision_vms(client, vm_names) + + vm_paths = [ + template.format(id=vm_name) + for vm_name in vm_names + for template in VM_ENDPOINTS + ] + service_global = _measure_service_group( + svc.uds_path, + GLOBAL_ENDPOINTS, + GLOBAL_ITERATIONS, + ) + service_vm = _measure_service_group(svc.uds_path, vm_paths, VM_ITERATIONS) + + gateway = GatewayInstance(svc.uds_path) + gateway.start() + gateway_results = _measure_gateway(gateway) + + result = { + "version": "0.1.0", + "timestamp": time.time(), + "vm_count": VM_COUNT, + "iterations": { + "service_global": GLOBAL_ITERATIONS, + "service_vm": VM_ITERATIONS, + "gateway": GATEWAY_ITERATIONS, + }, + "gates": { + "service_global": { + "p95_ms": GLOBAL_GATE_P95_MS, + "max_ms": GLOBAL_GATE_MAX_MS, + }, + "service_vm": { + "p95_ms": VM_GATE_P95_MS, + "max_ms": VM_GATE_MAX_MS, + }, + "gateway": { + "p95_ms": GATEWAY_GATE_P95_MS, + "max_ms": GATEWAY_GATE_MAX_MS, + }, + }, + "groups": { + "service_global": service_global, + "service_vm": service_vm, + "gateway": gateway_results, + }, + } + + for group, endpoints in result["groups"].items(): + slowest = max(endpoints.items(), key=lambda item: item[1]["p95_ms"]) + print( + f"{group}: slowest p95 {slowest[0]} = " + f"{slowest[1]['p95_ms']}ms max={slowest[1]['max_ms']}ms" + ) + + _save_benchmark(result) + _check_gates(result) + finally: + if gateway is not None: + gateway.stop() + for name in vm_names: + try: + client.delete(f"/delete/{name}", timeout=30) + except Exception: + pass + svc.stop() diff --git a/uv.lock b/uv.lock index e334c102..85de26c3 100644 --- a/uv.lock +++ b/uv.lock @@ -96,7 +96,7 @@ wheels = [ [[package]] name = "capsem" -version = "1.2.1779673506" +version = "1.2.1780103109" source = { editable = "." } dependencies = [ { name = "blake3" }, From 1299bd5c0677d6252c05ae2055372f4609f6b7b6 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Fri, 29 May 2026 21:17:32 -0400 Subject: [PATCH 20/35] fix: render stopped tui sessions --- CHANGELOG.md | 6 +++ crates/capsem-tui/src/app.rs | 5 ++ crates/capsem-tui/src/main.rs | 34 +++++++++---- crates/capsem-tui/src/terminal.rs | 12 +++++ crates/capsem-tui/src/tests.rs | 45 ++++++++++++++++++ crates/capsem-tui/src/ui.rs | 79 +++++++++++++++++++++++++++++-- sprints/tui-control/MASTER.md | 9 ++++ sprints/tui-control/tracker.md | 13 +++-- 8 files changed, 187 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f81da2d..6d483825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 health/token/status reads with committed `benchmarks/endpoint-latency/` results. +### Fixed +- Fixed `capsem-tui` stopped-session rendering so stopped/suspended/failed + tabs are greyed, the main pane shows a `Press Enter to resume` affordance + instead of going blank, and the terminal bridge disconnects instead of trying + to attach a WebSocket to an inactive VM. + ### Changed - Split Google into its own `sprints/google/` meta sprint covering Gmail, Drive, gcloud, Firebase, Firebase Realtime DB remote comms, Jet Ski, Gemini, diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index fd3158fd..785294d4 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -130,6 +130,11 @@ impl App { self.overlay = AppOverlay::Confirm; return AppAction::Consumed; } + if key.code == KeyCode::Enter && key.modifiers.is_empty() { + if let Some(action) = self.active_resume_action() { + return AppAction::Invoke(action); + } + } if is_previous_key(key) { self.previous_session(); return AppAction::Consumed; diff --git a/crates/capsem-tui/src/main.rs b/crates/capsem-tui/src/main.rs index aed65fbd..1cad345f 100644 --- a/crates/capsem-tui/src/main.rs +++ b/crates/capsem-tui/src/main.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result}; use capsem_tui::app::{App, AppAction, ControlAction}; use capsem_tui::fixture::FixtureProvider; use capsem_tui::gateway_provider::{ActionOutcome, GatewayProvider}; -use capsem_tui::model::{AppState, ServiceStatus}; +use capsem_tui::model::{AppState, ServiceStatus, SessionLifecycle}; use capsem_tui::provider::StateProvider; use capsem_tui::terminal::{key_to_terminal_bytes, TerminalBridge, TerminalSurface}; use capsem_tui::ui::{render_app, render_snapshot, render_svg_snapshot}; @@ -338,14 +338,20 @@ fn sync_terminal_connection( cols: u16, rows: u16, ) -> bool { - let active_id = &app.state().active_session_id; - if active_id.is_empty() { - return false; - } + let active_id = match active_terminal_session_id(app.state()) { + Some(active_id) => active_id, + None => { + if connected.take().is_some() { + bridge.disconnect(); + return true; + } + return false; + } + }; let cols = cols.max(1); let rows = rows.max(1); match connected { - Some(current) if current.session_id == *active_id => { + Some(current) if current.session_id == active_id => { if current.cols == cols && current.rows == rows { return false; } @@ -355,9 +361,9 @@ fn sync_terminal_connection( true } _ => { - bridge.connect(active_id.clone(), cols, rows); + bridge.connect(active_id.to_string(), cols, rows); *connected = Some(ConnectedTerminal { - session_id: active_id.clone(), + session_id: active_id.to_string(), cols, rows, }); @@ -366,6 +372,18 @@ fn sync_terminal_connection( } } +fn active_terminal_session_id(state: &AppState) -> Option<&str> { + let session = state.active_session()?; + if matches!( + session.lifecycle, + SessionLifecycle::Working | SessionLifecycle::WaitingForInput + ) { + Some(session.id.as_str()) + } else { + None + } +} + fn refresh_state(app: &mut App, provider: Option<&GatewayProvider>) -> bool { let Some(provider) = provider else { return false; diff --git a/crates/capsem-tui/src/terminal.rs b/crates/capsem-tui/src/terminal.rs index 2e3d1e90..08978bc4 100644 --- a/crates/capsem-tui/src/terminal.rs +++ b/crates/capsem-tui/src/terminal.rs @@ -49,6 +49,10 @@ impl TerminalBridge { let _ = self.commands.send(TerminalCommand::Resize { cols, rows }); } + pub fn disconnect(&self) { + let _ = self.commands.send(TerminalCommand::Disconnect); + } + pub fn drain_events(&self) -> Vec { let mut events = Vec::new(); while let Ok(event) = self.events.try_recv() { @@ -76,6 +80,7 @@ enum TerminalCommand { cols: u16, rows: u16, }, + Disconnect, Shutdown, } @@ -152,6 +157,13 @@ async fn run_terminal_manager( let _ = input.send(TerminalInput::Resize { cols, rows }); } } + TerminalCommand::Disconnect => { + if let Some(task) = active_task.take() { + task.abort(); + } + active_input = None; + active_session_id.clear(); + } TerminalCommand::Shutdown => { if let Some(task) = active_task.take() { task.abort(); diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index bb739e41..40ed02f7 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -71,6 +71,47 @@ fn tab_colors_use_selected_yellow_and_unselected_blue_only() { ); } +#[test] +fn stopped_session_renders_resume_prompt_and_grey_tab() { + let mut state = fixture_state(); + state.sessions[0].lifecycle = SessionLifecycle::Idle; + + let snapshot = render_snapshot(&state, 100, 24).expect("render stopped snapshot"); + assert!( + snapshot.contains("Press Enter to resume"), + "stopped sessions should render an explicit recovery affordance instead of a blank pane" + ); + assert!(snapshot.contains("stopped")); + + let buffer = render_test_buffer(&state, 100, 24).expect("render stopped buffer"); + let row = buffer.area.height - 1; + let stopped_number = find_cell_x(&buffer, row, "1 profile-v2"); + let stopped_label = stopped_number + 3; + + assert_eq!(buffer_cell(&buffer, stopped_number, row).bg, grey()); + assert_eq!(buffer_cell(&buffer, stopped_label, row).fg, grey()); + assert!( + buffer_cell(&buffer, stopped_label, row) + .modifier + .contains(Modifier::DIM), + "stopped tab labels should read as inactive" + ); +} + +#[test] +fn enter_resumes_stopped_active_session_instead_of_forwarding_to_terminal() { + let mut state = fixture_state(); + state.sessions[0].lifecycle = SessionLifecycle::Idle; + let mut app = App::new(state); + + assert_eq!( + app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE)), + AppAction::Invoke(ControlAction::Resume { + name: "profile-v2".to_string() + }) + ); +} + #[test] fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { let mut app = App::new(fixture_state()); @@ -562,6 +603,10 @@ fn blue() -> Color { Color::Rgb(137, 180, 250) } +fn grey() -> Color { + Color::Rgb(127, 137, 180) +} + async fn read_http_request(stream: &mut tokio::net::TcpStream) -> String { let mut request = Vec::new(); let mut buffer = [0_u8; 256]; diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 604fb0a0..95159d27 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -8,7 +8,7 @@ use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use ratatui::{Frame, Terminal}; use crate::app::{App, AppOverlay, ControlAction}; -use crate::model::{AppState, ServiceStatus, SessionSummary}; +use crate::model::{AppState, ServiceStatus, SessionLifecycle, SessionSummary}; use crate::terminal::{TerminalColor, TerminalLine, TerminalStyle, TerminalSurface}; const MAX_VISIBLE_TABS: usize = 4; @@ -159,11 +159,24 @@ fn render_terminal_surface( state: &AppState, terminal: Option<&TerminalSurface>, ) { + let Some(session) = state.active_session() else { + frame.render_widget( + Paragraph::new(Line::from(Span::styled(" no sessions", muted_style()))) + .alignment(Alignment::Center), + area, + ); + return; + }; + if !session_accepts_terminal(session.lifecycle) { + render_inactive_session_surface(frame, area, session); + return; + } + let Some(terminal) = terminal else { - frame.render_widget(Paragraph::new(""), area); + render_waiting_terminal_surface(frame, area, session); return; }; - let active_id = state.active_session_id.as_str(); + let active_id = session.id.as_str(); let mut lines = terminal .styled_lines_for(active_id, area.height as usize) .into_iter() @@ -181,6 +194,35 @@ fn render_terminal_surface( frame.render_widget(Paragraph::new(lines), area); } +fn render_waiting_terminal_surface(frame: &mut Frame<'_>, area: Rect, session: &SessionSummary) { + let lines = vec![Line::from(vec![ + Span::styled("connecting terminal ", muted_style()), + Span::styled( + session.id.clone(), + muted_style().add_modifier(Modifier::BOLD), + ), + ])]; + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); +} + +fn render_inactive_session_surface(frame: &mut Frame<'_>, area: Rect, session: &SessionSummary) { + let lines = vec![ + Line::from(Span::styled( + session.id.clone(), + muted_style().add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + inactive_session_label(session.lifecycle), + muted_style(), + )), + Line::from(Span::styled( + "Press Enter to resume", + status_base_style().add_modifier(Modifier::BOLD), + )), + ]; + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); +} + fn terminal_line_to_ratatui(line: TerminalLine) -> Line<'static> { let spans = line .spans() @@ -218,6 +260,22 @@ fn terminal_style_to_ratatui(style: TerminalStyle) -> Style { result } +fn session_accepts_terminal(lifecycle: SessionLifecycle) -> bool { + matches!( + lifecycle, + SessionLifecycle::Working | SessionLifecycle::WaitingForInput + ) +} + +fn inactive_session_label(lifecycle: SessionLifecycle) -> &'static str { + match lifecycle { + SessionLifecycle::Idle => "stopped", + SessionLifecycle::Suspended => "suspended", + SessionLifecycle::Failed => "failed", + SessionLifecycle::Working | SessionLifecycle::WaitingForInput => "inactive", + } +} + fn terminal_color_to_ratatui(color: TerminalColor) -> Option { match color { TerminalColor::Default => None, @@ -432,7 +490,7 @@ fn push_tab( max_width: usize, used: &mut usize, ) -> bool { - let tone = TabTone::from_active(active); + let tone = TabTone::from_session(session, active); let number = format!(" {} ", index + 1); let label = format!( " {}{} ", @@ -455,6 +513,9 @@ fn push_tab( if active { label_style = label_style.add_modifier(Modifier::BOLD); } + if tone == TabTone::Inactive { + label_style = label_style.add_modifier(Modifier::DIM); + } spans.push(Span::styled(label, label_style)); *used += width; true @@ -513,10 +574,17 @@ fn stats_style() -> Style { enum TabTone { Selected, Unselected, + Inactive, } impl TabTone { - const fn from_active(active: bool) -> Self { + const fn from_session(session: &SessionSummary, active: bool) -> Self { + if matches!( + session.lifecycle, + SessionLifecycle::Idle | SessionLifecycle::Suspended | SessionLifecycle::Failed + ) { + return Self::Inactive; + } if active { Self::Selected } else { @@ -528,6 +596,7 @@ impl TabTone { match self { Self::Selected => ATTENTION, Self::Unselected => ACTIVE, + Self::Inactive => MUTED, } } } diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 8d6830ae..fd4a9dee 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -120,6 +120,11 @@ attention markers. session directories, raw session DB queries no longer pay a 100ms watchdog floor, `/stats` has an empty/read-only fast path, and policy-context exports dedupe by security event before fixture projection. +- Fixed inactive-session handling after the proof VMs were stopped: stopped, + suspended, and failed sessions now render a greyed tab plus a centered + `Press Enter to resume` prompt, Enter invokes resume for the active inactive + session, and the terminal WebSocket bridge disconnects instead of reconnecting + to stopped VM sockets. ## Testing Gate @@ -137,3 +142,7 @@ attention markers. p95 0.798ms, slowest per-VM read `/files` p95 2.491ms, and gateway `/status` p95 0.223ms. Concurrent boot pressure remains a separate follow-up because endpoint speed should not depend on parallel provisioning setup. +- Regression: `cargo test -p capsem-tui` covers stopped-session prompt, + greyed inactive tab tone, and Enter-to-resume behavior. Live snapshot against + the installed stopped `tui-proof-*` sessions shows the prompt instead of a + blank pane. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 5393a5be..7479d324 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -146,11 +146,17 @@ `wait_exec_ready` miss before latency measurement. Sequentially provisioning 8 live VMs is stable, so endpoint latency remains gated separately from a future concurrent-boot pressure test. +- Stopped-session bug: stopped sessions were still selectable tabs, but the + main pane only rendered live terminal buffers and the WebSocket manager still + tried to connect. Stopped/suspended/failed tabs now grey out, the pane shows + `Press Enter to resume`, Enter invokes resume for the active inactive + session, and the terminal bridge disconnects from inactive tabs. ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui` (25 tests). -- TUI latency/provider: `cargo test -p capsem-tui` (25 tests), including +- Unit/contract: `cargo test -p capsem-tui` (27 tests), including + stopped-session resume prompt, grey tab, and Enter-to-resume coverage. +- TUI latency/provider: `cargo test -p capsem-tui` (27 tests), including token reuse and raw local latency preservation coverage. - Process IPC: `cargo test -p capsem-process` (120 tests), including `connection_teardown_aborts_writer_and_lifecycle_tasks`. @@ -164,7 +170,8 @@ - Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`; `cargo run -p capsem-tui -- --fixture --snapshot --width 120 --height 30`; `cargo run -p capsem-tui -- --fixture --snapshot-svg --width 120 --height 30`; - `just dev-tui`. + `cargo run -p capsem-tui -- --snapshot --width 120 --height 30` against + the installed stopped proof sessions; `just dev-tui`. - Gateway wiring: `GatewayProvider::load_async` authenticated HTTP mock test plus live local snapshot through the installed gateway. - Service actions: confirmed action key tests plus authenticated mock gateway From f60bb67101278c80e776b1604a9bfb34cbd62ef2 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 08:00:58 -0400 Subject: [PATCH 21/35] fix: surface tui suspend shortcut --- CHANGELOG.md | 3 +++ crates/capsem-tui/src/tests.rs | 2 +- crates/capsem-tui/src/ui.rs | 1 + sprints/tui-control/MASTER.md | 4 ++++ sprints/tui-control/tracker.md | 6 +++++- 5 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d483825..5e20be5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added Linux host doctor smoke probes for `KVM_GET_API_VERSION` and `/dev/vhost-vsock` openability so bootstrap verifies usable KVM devices, not just filesystem permissions. +- Added a compact `Alt+s` suspend hint beside the `capsem-tui` service status + segment so the primary lifecycle shortcut is visible without reopening the + full help overlay. - Added Linux KVM doctor coverage that creates and resolves symlinks under `/tmp`, keeping link-heavy cache/tool probes off the VirtioFS workspace while diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 40ed02f7..5a52fc0d 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -28,7 +28,7 @@ fn fixture_models_global_service_state_and_session_indicators() { fn snapshot_contains_light_bar_tabs_and_active_desktop() { let snapshot = render_snapshot(&fixture_state(), 100, 24).expect("render snapshot"); - assert!(snapshot.contains(" 18ms●")); + assert!(snapshot.contains(" 18ms● Alt+s")); assert!(snapshot.contains("1 profile-v2")); assert!(snapshot.contains("2 linux-os!")); assert!(snapshot.contains("◷ 47m | # 38.4k | $ 0.21")); diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 95159d27..74834720 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -105,6 +105,7 @@ fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { service_dot(service.status), service_style(service.status, service.latency.as_millis()), ), + Span::styled(" Alt+s", muted_style()), Span::styled(" ", base), ]; if let Some(attempt) = service.reconnect_attempt { diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index fd4a9dee..8ebd17f3 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -125,6 +125,8 @@ attention markers. `Press Enter to resume` prompt, Enter invokes resume for the active inactive session, and the terminal WebSocket bridge disconnects instead of reconnecting to stopped VM sockets. +- Added a persistent left-bar `Alt+s` hint next to service latency/status so + suspend is discoverable without adding back the old full-width help strip. ## Testing Gate @@ -146,3 +148,5 @@ attention markers. greyed inactive tab tone, and Enter-to-resume behavior. Live snapshot against the installed stopped `tui-proof-*` sessions shows the prompt instead of a blank pane. +- UI polish: `cargo test -p capsem-tui` and snapshot output cover the left-side + `Alt+s` status-bar hint. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 7479d324..cb0a6254 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -151,11 +151,15 @@ tried to connect. Stopped/suspended/failed tabs now grey out, the pane shows `Press Enter to resume`, Enter invokes resume for the active inactive session, and the terminal bridge disconnects from inactive tabs. +- Discoverability polish: the persistent left status segment now includes a + compact `Alt+s` suspend hint beside service latency/status. The full command + list remains in the help overlay. ## Coverage Ledger - Unit/contract: `cargo test -p capsem-tui` (27 tests), including - stopped-session resume prompt, grey tab, and Enter-to-resume coverage. + stopped-session resume prompt, grey tab, Enter-to-resume coverage, and the + left-side `Alt+s` status-bar hint. - TUI latency/provider: `cargo test -p capsem-tui` (27 tests), including token reuse and raw local latency preservation coverage. - Process IPC: `cargo test -p capsem-process` (120 tests), including From d47a889a29a9e10d79ec4e4828fc3421935f8c06 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 08:08:34 -0400 Subject: [PATCH 22/35] fix: pin tui suspend hint left --- CHANGELOG.md | 6 +++--- crates/capsem-tui/src/tests.rs | 2 +- crates/capsem-tui/src/ui.rs | 3 +-- sprints/tui-control/MASTER.md | 8 ++++---- sprints/tui-control/tracker.md | 8 ++++---- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e20be5a..2086c805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,9 +100,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added Linux host doctor smoke probes for `KVM_GET_API_VERSION` and `/dev/vhost-vsock` openability so bootstrap verifies usable KVM devices, not just filesystem permissions. -- Added a compact `Alt+s` suspend hint beside the `capsem-tui` service status - segment so the primary lifecycle shortcut is visible without reopening the - full help overlay. +- Added a far-left `help: alt+s` suspend hint to the `capsem-tui` status bar + so the primary lifecycle shortcut is visible without reopening the full help + overlay. - Added Linux KVM doctor coverage that creates and resolves symlinks under `/tmp`, keeping link-heavy cache/tool probes off the VirtioFS workspace while diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 5a52fc0d..5abbb548 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -28,7 +28,7 @@ fn fixture_models_global_service_state_and_session_indicators() { fn snapshot_contains_light_bar_tabs_and_active_desktop() { let snapshot = render_snapshot(&fixture_state(), 100, 24).expect("render snapshot"); - assert!(snapshot.contains(" 18ms● Alt+s")); + assert!(snapshot.contains("help: alt+s 18ms●")); assert!(snapshot.contains("1 profile-v2")); assert!(snapshot.contains("2 linux-os!")); assert!(snapshot.contains("◷ 47m | # 38.4k | $ 0.21")); diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 74834720..cc4e9b9a 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -99,13 +99,12 @@ fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { frame.render_widget(Paragraph::new("").style(base), area); let mut left = vec![ - Span::styled(" ", base), + Span::styled("help: alt+s ", muted_style()), Span::styled(format!("{:>4}ms", service.latency.as_millis()), base), Span::styled( service_dot(service.status), service_style(service.status, service.latency.as_millis()), ), - Span::styled(" Alt+s", muted_style()), Span::styled(" ", base), ]; if let Some(attempt) = service.reconnect_attempt { diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 8ebd17f3..4e1b140e 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -125,8 +125,8 @@ attention markers. `Press Enter to resume` prompt, Enter invokes resume for the active inactive session, and the terminal WebSocket bridge disconnects instead of reconnecting to stopped VM sockets. -- Added a persistent left-bar `Alt+s` hint next to service latency/status so - suspend is discoverable without adding back the old full-width help strip. +- Added a far-left `help: alt+s` hint before service latency/status so suspend + is discoverable without adding back the old full-width help strip. ## Testing Gate @@ -148,5 +148,5 @@ attention markers. greyed inactive tab tone, and Enter-to-resume behavior. Live snapshot against the installed stopped `tui-proof-*` sessions shows the prompt instead of a blank pane. -- UI polish: `cargo test -p capsem-tui` and snapshot output cover the left-side - `Alt+s` status-bar hint. +- UI polish: `cargo test -p capsem-tui` and snapshot output cover the far-left + `help: alt+s` status-bar hint. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index cb0a6254..cf269dd5 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -151,15 +151,15 @@ tried to connect. Stopped/suspended/failed tabs now grey out, the pane shows `Press Enter to resume`, Enter invokes resume for the active inactive session, and the terminal bridge disconnects from inactive tabs. -- Discoverability polish: the persistent left status segment now includes a - compact `Alt+s` suspend hint beside service latency/status. The full command - list remains in the help overlay. +- Discoverability polish: the persistent status segment now starts with + `help: alt+s` before service latency/status. The full command list remains + in the help overlay. ## Coverage Ledger - Unit/contract: `cargo test -p capsem-tui` (27 tests), including stopped-session resume prompt, grey tab, Enter-to-resume coverage, and the - left-side `Alt+s` status-bar hint. + far-left `help: alt+s` status-bar hint. - TUI latency/provider: `cargo test -p capsem-tui` (27 tests), including token reuse and raw local latency preservation coverage. - Process IPC: `cargo test -p capsem-process` (120 tests), including From f5a73773869f67cabc9393e872e79873824c1a6c Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 08:18:52 -0400 Subject: [PATCH 23/35] fix: make tui create profile aware --- CHANGELOG.md | 9 +- crates/capsem-tui/src/app.rs | 123 ++++++++++++++++++- crates/capsem-tui/src/fixture.rs | 18 ++- crates/capsem-tui/src/gateway_provider.rs | 113 +++++++++++++++++- crates/capsem-tui/src/model.rs | 9 ++ crates/capsem-tui/src/tests.rs | 138 +++++++++++++++++++++- crates/capsem-tui/src/ui.rs | 57 ++++++++- sprints/tui-control/MASTER.md | 7 ++ sprints/tui-control/tracker.md | 18 ++- 9 files changed, 466 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2086c805..b588f32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 messages, and basic ANSI cleanup for the Ratatui surface. - Added hidden `capsem-tui` overlays for help, active-session statistics, and the session list so the normal terminal surface stays minimal. -- Added confirmed `capsem-tui` service actions for creating, resuming, - suspending, stopping, and deleting sessions through the installed HTTP - gateway without blocking the terminal UI. +- Added confirmed `capsem-tui` service actions for resuming, suspending, + stopping, and deleting sessions through the installed HTTP gateway without + blocking the terminal UI. +- Added a profile-aware `capsem-tui` new-session dialog with an editable + prefilled `tmp-*` session name and live profile selection before + provisioning. - Added `capsem-tui` to local install/package payloads so the TUI is available from `~/.capsem/bin/capsem-tui` after installation. - Added `capsem_terminal_snapshot` to the Capsem MCP server so agents can diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index 785294d4..083d3e97 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -17,12 +17,13 @@ pub enum AppOverlay { Help, Stats, Home, + Create, Confirm, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum ControlAction { - CreateEphemeral, + CreateSession { name: String, profile_id: String }, Resume { name: String }, Suspend { id: String }, Stop { id: String }, @@ -32,7 +33,7 @@ pub enum ControlAction { impl ControlAction { pub const fn label(&self) -> &'static str { match self { - Self::CreateEphemeral => "create", + Self::CreateSession { .. } => "create", Self::Resume { .. } => "resume", Self::Suspend { .. } => "suspend", Self::Stop { .. } => "stop", @@ -42,7 +43,7 @@ impl ControlAction { pub fn target(&self) -> &str { match self { - Self::CreateEphemeral => "new ephemeral session", + Self::CreateSession { name, .. } => name, Self::Resume { name } | Self::Suspend { id: name } | Self::Stop { id: name } @@ -57,6 +58,13 @@ pub struct App { active_index: usize, overlay: AppOverlay, pending_action: Option, + create_draft: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateDraft { + pub name: String, + pub selected_profile: usize, } impl App { @@ -71,6 +79,7 @@ impl App { active_index, overlay: AppOverlay::None, pending_action: None, + create_draft: None, } } @@ -86,6 +95,10 @@ impl App { self.pending_action.as_ref() } + pub fn create_draft(&self) -> Option<&CreateDraft> { + self.create_draft.as_ref() + } + pub fn replace_state(&mut self, mut state: AppState) { state.service.control_message = self.state.service.control_message.clone(); let previous_active_id = self.state.active_session_id.clone(); @@ -116,6 +129,9 @@ impl App { if let Some(action) = self.handle_pending_action_key(key) { return action; } + if self.overlay == AppOverlay::Create { + return self.handle_create_key(key); + } if self.handle_overlay_key(key) { return AppAction::Consumed; } @@ -125,6 +141,10 @@ impl App { } return AppAction::Consumed; } + if is_new_key(key) { + self.open_create(); + return AppAction::Consumed; + } if let Some(action) = self.control_action_for_key(key) { self.pending_action = Some(action); self.overlay = AppOverlay::Confirm; @@ -201,6 +221,7 @@ impl App { next }; self.pending_action = None; + self.create_draft = None; true } @@ -226,7 +247,6 @@ impl App { return None; } match key.code { - KeyCode::Char('n' | 'N') => Some(ControlAction::CreateEphemeral), KeyCode::Char('r' | 'R') => self.active_resume_action(), KeyCode::Char('s' | 'S') => self.active_suspend_action(), KeyCode::Char('t' | 'T') => self.active_id().map(|id| ControlAction::Stop { id }), @@ -263,6 +283,70 @@ impl App { .active_session() .map(|session| session.id.clone()) } + + fn open_create(&mut self) { + self.pending_action = None; + self.create_draft = Some(CreateDraft { + name: next_tmp_name(&self.state), + selected_profile: default_profile_index(&self.state), + }); + self.overlay = AppOverlay::Create; + } + + fn handle_create_key(&mut self, key: KeyEvent) -> AppAction { + match key.code { + KeyCode::Esc => { + self.create_draft = None; + self.overlay = AppOverlay::None; + AppAction::Consumed + } + KeyCode::Enter => { + let Some(draft) = self.create_draft.clone() else { + self.overlay = AppOverlay::None; + return AppAction::Consumed; + }; + let name = draft.name.trim().to_string(); + if name.is_empty() { + return AppAction::Consumed; + } + let profile_id = selected_profile_id(&self.state, draft.selected_profile); + self.create_draft = None; + self.overlay = AppOverlay::None; + AppAction::Invoke(ControlAction::CreateSession { name, profile_id }) + } + KeyCode::Up => { + if let Some(draft) = &mut self.create_draft { + draft.selected_profile = draft.selected_profile.saturating_sub(1); + } + AppAction::Consumed + } + KeyCode::Down => { + let max_index = self.state.profiles.len().saturating_sub(1); + if let Some(draft) = &mut self.create_draft { + draft.selected_profile = + draft.selected_profile.saturating_add(1).min(max_index); + } + AppAction::Consumed + } + KeyCode::Backspace => { + if let Some(draft) = &mut self.create_draft { + draft.name.pop(); + } + AppAction::Consumed + } + KeyCode::Char(ch) + if !key.modifiers.intersects( + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, + ) => + { + if let Some(draft) = &mut self.create_draft { + draft.name.push(ch); + } + AppAction::Consumed + } + _ => AppAction::Consumed, + } + } } fn is_exit_key(key: KeyEvent) -> bool { @@ -280,10 +364,41 @@ fn is_next_key(key: KeyEvent) -> bool { is_alt_key(key.modifiers) && matches!(key.code, KeyCode::Right) } +fn is_new_key(key: KeyEvent) -> bool { + is_alt_key(key.modifiers) && matches!(key.code, KeyCode::Char('n' | 'N')) +} + fn is_alt_key(modifiers: KeyModifiers) -> bool { modifiers.contains(KeyModifiers::ALT) } +fn default_profile_index(state: &AppState) -> usize { + state + .profiles + .iter() + .position(|profile| profile.is_default) + .unwrap_or_default() +} + +fn selected_profile_id(state: &AppState, index: usize) -> String { + state + .profiles + .get(index) + .or_else(|| state.profiles.first()) + .map(|profile| profile.id.clone()) + .unwrap_or_else(|| "default".to_string()) +} + +fn next_tmp_name(state: &AppState) -> String { + for index in 1..1000 { + let candidate = format!("tmp-{index}"); + if state.sessions.iter().all(|session| session.id != candidate) { + return candidate; + } + } + "tmp".to_string() +} + fn select_index(key: KeyEvent) -> Option { if !is_alt_key(key.modifiers) { return None; diff --git a/crates/capsem-tui/src/fixture.rs b/crates/capsem-tui/src/fixture.rs index 6516e625..4ac9ff62 100644 --- a/crates/capsem-tui/src/fixture.rs +++ b/crates/capsem-tui/src/fixture.rs @@ -3,8 +3,8 @@ use std::time::Duration; use anyhow::Result; use crate::model::{ - AppState, Attention, ServiceState, ServiceStatus, SessionLifecycle, SessionStats, - SessionSummary, + AppState, Attention, ProfileOption, ServiceState, ServiceStatus, SessionLifecycle, + SessionStats, SessionSummary, }; use crate::provider::StateProvider; @@ -27,6 +27,20 @@ pub fn fixture_state() -> AppState { control_message: None, }, active_session_id: "profile-v2".to_string(), + profiles: vec![ + ProfileOption { + id: "corp-default".to_string(), + name: "Corp Default".to_string(), + description: Some("default profile".to_string()), + is_default: true, + }, + ProfileOption { + id: "linux-builder".to_string(), + name: "Linux Builder".to_string(), + description: Some("kernel and distro work".to_string()), + is_default: false, + }, + ], sessions: vec![ SessionSummary { id: "profile-v2".to_string(), diff --git a/crates/capsem-tui/src/gateway_provider.rs b/crates/capsem-tui/src/gateway_provider.rs index f78b2363..1e989e8a 100644 --- a/crates/capsem-tui/src/gateway_provider.rs +++ b/crates/capsem-tui/src/gateway_provider.rs @@ -7,8 +7,8 @@ use serde::Deserialize; use crate::app::ControlAction; use crate::model::{ - AppState, Attention, ServiceState, ServiceStatus, SessionLifecycle, SessionStats, - SessionSummary, + AppState, Attention, ProfileOption, ServiceState, ServiceStatus, SessionLifecycle, + SessionStats, SessionSummary, }; use crate::provider::StateProvider; @@ -78,7 +78,9 @@ impl GatewayProvider { let token = self.token().await?; let started = Instant::now(); let status = fetch_status(&self.client, &self.base_url, &token).await?; - Ok(status_response_to_state(status, started.elapsed())) + let mut state = status_response_to_state(status, started.elapsed()); + state.profiles = self.profile_options(&token, &state).await; + Ok(state) } pub fn invoke(&self, action: &ControlAction) -> Result { @@ -93,6 +95,13 @@ impl GatewayProvider { let token = self.token().await?; invoke_action(&self.client, &self.base_url, &token, action).await } + + async fn profile_options(&self, token: &str, state: &AppState) -> Vec { + match fetch_profiles(&self.client, &self.base_url, token).await { + Ok(profiles) if !profiles.is_empty() => profiles, + _ => profiles_from_sessions(state), + } + } } impl StateProvider for GatewayProvider { @@ -138,6 +147,25 @@ async fn fetch_status( .context("parse capsem gateway status response") } +async fn fetch_profiles( + client: &reqwest::Client, + base_url: &str, + token: &str, +) -> Result> { + let response: ProfilesResponse = client + .get(format!("{base_url}/profiles")) + .bearer_auth(token) + .send() + .await + .context("fetch capsem gateway profiles")? + .error_for_status() + .context("capsem gateway profiles request failed")? + .json() + .await + .context("parse capsem gateway profiles response")?; + Ok(response.into_options()) +} + fn gateway_port() -> Option { let path = run_dir().join("gateway.port"); let raw = std::fs::read_to_string(path).ok()?; @@ -177,7 +205,36 @@ fn status_response_to_state(status: StatusResponse, latency: Duration) -> AppSta }, active_session_id, sessions, + profiles: Vec::new(), + } +} + +fn profiles_from_sessions(state: &AppState) -> Vec { + let mut profiles = Vec::new(); + for session in &state.sessions { + if session.profile.is_empty() + || profiles + .iter() + .any(|profile: &ProfileOption| profile.id == session.profile) + { + continue; + } + profiles.push(ProfileOption { + id: session.profile.clone(), + name: session.profile.clone(), + description: None, + is_default: profiles.is_empty(), + }); + } + if profiles.is_empty() { + profiles.push(ProfileOption { + id: "default".to_string(), + name: "default".to_string(), + description: None, + is_default: true, + }); } + profiles } fn vm_response_to_summary(vm: VmSummary) -> SessionSummary { @@ -273,11 +330,15 @@ async fn invoke_action( action: &ControlAction, ) -> Result { match action { - ControlAction::CreateEphemeral => { + ControlAction::CreateSession { name, profile_id } => { let response = client .post(join_url(base_url, &["provision"])?) .bearer_auth(token) - .json(&serde_json::json!({ "persistent": false })) + .json(&serde_json::json!({ + "name": name, + "persistent": true, + "profile_id": profile_id, + })) .send() .await .context("create capsem session")?; @@ -405,6 +466,48 @@ struct VmSummary { total_file_events: Option, } +#[derive(Debug, Deserialize)] +struct ProfilesResponse { + #[serde(default)] + default_profile: Option, + #[serde(default)] + profiles: Vec, +} + +impl ProfilesResponse { + fn into_options(self) -> Vec { + let default = self.default_profile.unwrap_or_default(); + self.profiles + .into_iter() + .filter_map(|record| { + let id = record.profile.id?; + let name = record.profile.name.unwrap_or_else(|| id.clone()); + Some(ProfileOption { + is_default: id == default, + id, + name, + description: record.profile.best_for, + }) + }) + .collect() + } +} + +#[derive(Debug, Deserialize)] +struct ProfileRecordResponse { + profile: ProfileResponse, +} + +#[derive(Debug, Deserialize)] +struct ProfileResponse { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + best_for: Option, +} + #[cfg(test)] pub(crate) fn state_from_status_json_for_test(raw: &str, latency: Duration) -> Result { let response: StatusResponse = serde_json::from_str(raw)?; diff --git a/crates/capsem-tui/src/model.rs b/crates/capsem-tui/src/model.rs index ebbcb119..2fdaf9f5 100644 --- a/crates/capsem-tui/src/model.rs +++ b/crates/capsem-tui/src/model.rs @@ -5,6 +5,7 @@ pub struct AppState { pub service: ServiceState, pub active_session_id: String, pub sessions: Vec, + pub profiles: Vec, } impl AppState { @@ -15,6 +16,14 @@ impl AppState { } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProfileOption { + pub id: String, + pub name: String, + pub description: Option, + pub is_default: bool, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct ServiceState { pub status: ServiceStatus, diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 5abbb548..5943cc97 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -176,8 +176,7 @@ fn shell_commands_are_alt_owned() { app.handle_key(key(KeyCode::Char('n'), KeyModifiers::ALT)), AppAction::Consumed ); - assert_eq!(app.overlay(), AppOverlay::Confirm); - assert_eq!(app.pending_action(), Some(&ControlAction::CreateEphemeral)); + assert_eq!(app.overlay(), AppOverlay::Create); assert_eq!( app.handle_key(key(KeyCode::Esc, KeyModifiers::NONE)), @@ -196,6 +195,41 @@ fn shell_commands_are_alt_owned() { ); } +#[test] +fn create_overlay_selects_profile_and_edits_prefilled_name() { + let mut app = App::new(fixture_state()); + + assert_eq!( + app.handle_key(key(KeyCode::Char('n'), KeyModifiers::ALT)), + AppAction::Consumed + ); + let snapshot = render_app_snapshot(&app, 100, 24).expect("render create dialog"); + assert!(snapshot.contains("new session")); + assert!(snapshot.contains("name")); + assert!(snapshot.contains("tmp-1")); + assert!(snapshot.contains("corp-default")); + assert!(snapshot.contains("linux-builder")); + + assert_eq!( + app.handle_key(key(KeyCode::Down, KeyModifiers::NONE)), + AppAction::Consumed + ); + for ch in ['-', 'p', 'r', 'o', 'o', 'f'] { + assert_eq!( + app.handle_key(key(KeyCode::Char(ch), KeyModifiers::NONE)), + AppAction::Consumed + ); + } + + assert_eq!( + app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE)), + AppAction::Invoke(ControlAction::CreateSession { + name: "tmp-1-proof".to_string(), + profile_id: "linux-builder".to_string() + }) + ); +} + #[test] fn refresh_preserves_active_session_when_it_still_exists() { let mut app = App::new(fixture_state()); @@ -467,12 +501,16 @@ async fn gateway_provider_reuses_token_across_status_refreshes() { let server = tokio::spawn(async move { let mut token_requests = 0; let mut status_requests = 0; - for _ in 0..3 { + let mut profile_requests = 0; + for _ in 0..5 { let (mut stream, _) = listener.accept().await.expect("accept request"); let request = read_http_request(&mut stream).await; if request.contains("GET /token ") { token_requests += 1; write_json_response(&mut stream, r#"{"token":"test-token"}"#).await; + } else if request.contains("GET /profiles ") { + profile_requests += 1; + write_json_response(&mut stream, gateway_profiles_body()).await; } else { status_requests += 1; assert!( @@ -489,11 +527,18 @@ async fn gateway_provider_reuses_token_across_status_refreshes() { } assert_eq!(token_requests, 1, "token should be cached across refreshes"); assert_eq!(status_requests, 2); + assert_eq!( + profile_requests, 2, + "profile list should stay live across refreshes" + ); }); let provider = GatewayProvider::new(format!("http://{addr}")); provider.load_async().await.expect("initial load"); - provider.load_async().await.expect("refresh load"); + let refreshed = provider.load_async().await.expect("refresh load"); + assert_eq!(refreshed.profiles.len(), 2); + assert_eq!(refreshed.profiles[0].id, "corp-default"); + assert!(refreshed.profiles[0].is_default); server.await.expect("server task"); } @@ -536,6 +581,43 @@ async fn gateway_provider_invokes_stop_over_authenticated_gateway() { server.await.expect("server task"); } +#[tokio::test] +async fn gateway_provider_invokes_named_profile_create_over_authenticated_gateway() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test gateway"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let request = read_http_request(&mut stream).await; + if request.contains("GET /token ") { + write_json_response(&mut stream, r#"{"token":"test-token"}"#).await; + } else { + assert!( + request.contains("POST /provision "), + "unexpected request: {request:?}" + ); + assert!(request.contains(r#""name":"tmp-1-proof""#)); + assert!(request.contains(r#""persistent":true"#)); + assert!(request.contains(r#""profile_id":"linux-builder""#)); + write_json_response(&mut stream, r#"{"id":"tmp-1-proof"}"#).await; + } + } + }); + + let outcome = GatewayProvider::new(format!("http://{addr}")) + .invoke_async(&ControlAction::CreateSession { + name: "tmp-1-proof".to_string(), + profile_id: "linux-builder".to_string(), + }) + .await + .expect("invoke create"); + + assert_eq!(outcome.message, "created tmp-1-proof"); + server.await.expect("server task"); +} + #[tokio::test] async fn gateway_provider_surfaces_action_error_body() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") @@ -620,6 +702,29 @@ async fn read_http_request(stream: &mut tokio::net::TcpStream) -> String { break; } } + let header_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) + .unwrap_or(request.len()); + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .or_else(|| { + headers + .lines() + .find_map(|line| line.strip_prefix("Content-Length:")) + }) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or_default(); + while request.len().saturating_sub(header_end) < content_length { + let bytes_read = stream.read(&mut buffer).await.expect("read request body"); + if bytes_read == 0 { + break; + } + request.extend_from_slice(&buffer[..bytes_read]); + } String::from_utf8_lossy(&request).into_owned() } @@ -676,3 +781,28 @@ fn gateway_status_body() -> &'static str { ] }"# } + +fn gateway_profiles_body() -> &'static str { + r#"{ + "mode": "settings_profiles_v2", + "default_profile": "corp-default", + "profiles": [ + { + "profile": { + "id": "corp-default", + "name": "Corp Default", + "best_for": "default profile" + }, + "source": "corp" + }, + { + "profile": { + "id": "linux-builder", + "name": "Linux Builder", + "best_for": "kernel and distro work" + }, + "source": "user" + } + ] + }"# +} diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index cc4e9b9a..e3fba930 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -7,7 +7,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use ratatui::{Frame, Terminal}; -use crate::app::{App, AppOverlay, ControlAction}; +use crate::app::{App, AppOverlay, ControlAction, CreateDraft}; use crate::model::{AppState, ServiceStatus, SessionLifecycle, SessionSummary}; use crate::terminal::{TerminalColor, TerminalLine, TerminalStyle, TerminalSurface}; @@ -30,7 +30,7 @@ pub fn render_with_terminal( state: &AppState, terminal: Option<&TerminalSurface>, ) { - render_layout(frame, state, terminal, AppOverlay::None, None); + render_layout(frame, state, terminal, AppOverlay::None, None, None); } pub fn render_app(frame: &mut Frame<'_>, app: &App, terminal: Option<&TerminalSurface>) { @@ -40,6 +40,7 @@ pub fn render_app(frame: &mut Frame<'_>, app: &App, terminal: Option<&TerminalSu terminal, app.overlay(), app.pending_action(), + app.create_draft(), ); } @@ -49,6 +50,7 @@ fn render_layout( terminal: Option<&TerminalSurface>, overlay: AppOverlay, pending_action: Option<&ControlAction>, + create_draft: Option<&CreateDraft>, ) { let root = frame.area(); let chunks = Layout::default() @@ -58,7 +60,14 @@ fn render_layout( render_terminal_surface(frame, chunks[0], state, terminal); render_status_bar(frame, state, chunks[1]); - render_overlay(frame, chunks[0], state, overlay, pending_action); + render_overlay( + frame, + chunks[0], + state, + overlay, + pending_action, + create_draft, + ); } pub fn render_snapshot(state: &AppState, width: u16, height: u16) -> Result { @@ -290,6 +299,7 @@ fn render_overlay( state: &AppState, overlay: AppOverlay, pending_action: Option<&ControlAction>, + create_draft: Option<&CreateDraft>, ) { if overlay == AppOverlay::None { return; @@ -300,6 +310,7 @@ fn render_overlay( AppOverlay::Help => " help ", AppOverlay::Stats => " stats ", AppOverlay::Home => " sessions ", + AppOverlay::Create => " new session ", AppOverlay::Confirm => " confirm ", AppOverlay::None => "", }; @@ -314,6 +325,7 @@ fn render_overlay( AppOverlay::Help => help_lines(), AppOverlay::Stats => stats_lines(state), AppOverlay::Home => home_lines(state), + AppOverlay::Create => create_lines(state, create_draft), AppOverlay::Confirm => confirm_lines(pending_action), AppOverlay::None => Vec::new(), }; @@ -343,6 +355,9 @@ fn overlay_height(state: &AppState, overlay: AppOverlay) -> u16 { AppOverlay::Help => 10, AppOverlay::Stats => 10, AppOverlay::Home => (state.sessions.len() as u16).saturating_add(5).clamp(7, 16), + AppOverlay::Create => (state.profiles.len() as u16) + .saturating_add(8) + .clamp(10, 18), AppOverlay::Confirm => 6, AppOverlay::None => 0, } @@ -373,6 +388,42 @@ fn confirm_lines(action: Option<&ControlAction>) -> Vec> { ] } +fn create_lines(state: &AppState, draft: Option<&CreateDraft>) -> Vec> { + let mut lines = vec![overlay_title("new session")]; + let name = draft + .map(|draft| draft.name.as_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(" "); + lines.push(overlay_pair("name", name)); + lines.push(overlay_line("type to edit; Backspace deletes")); + lines.push(overlay_line( + "Up/Down selects profile; Enter creates; Esc cancels", + )); + lines.push(overlay_line("")); + lines.push(overlay_title("profiles")); + + if state.profiles.is_empty() { + lines.push(overlay_line("* default")); + return lines; + } + + let selected = draft + .map(|draft| draft.selected_profile) + .unwrap_or_default() + .min(state.profiles.len().saturating_sub(1)); + for (index, profile) in state.profiles.iter().take(8).enumerate() { + let marker = if index == selected { "*" } else { " " }; + let default = if profile.is_default { " default" } else { "" }; + lines.push(overlay_line(&format!( + "{marker} {} {}{}", + truncate(&profile.id, 20), + truncate(&profile.name, 22), + default + ))); + } + lines +} + fn stats_lines(state: &AppState) -> Vec> { let Some(session) = state.active_session() else { return vec![overlay_title("stats"), overlay_line("no active session")]; diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 4e1b140e..cab98083 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -127,6 +127,10 @@ attention markers. to stopped VM sockets. - Added a far-left `help: alt+s` hint before service latency/status so suspend is discoverable without adding back the old full-width help strip. +- Corrected `Alt+n` from one-key ephemeral provisioning into a profile-aware + new-session modal. The flow pre-fills the next unused `tmp-*` name, supports + typing/backspace, lets Up/Down choose a live `/profiles` entry, and sends a + named persistent `/provision` request with the selected `profile_id`. ## Testing Gate @@ -150,3 +154,6 @@ attention markers. blank pane. - UI polish: `cargo test -p capsem-tui` and snapshot output cover the far-left `help: alt+s` status-bar hint. +- New-session regression: `cargo test -p capsem-tui` covers create-modal + rendering, profile selection, name editing, and authenticated named-profile + provision request payloads. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index cf269dd5..24b0ccc5 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -154,14 +154,21 @@ - Discoverability polish: the persistent status segment now starts with `help: alt+s` before service latency/status. The full command list remains in the help overlay. +- New-session flow correction: `Alt+n` now opens a `new session` modal instead + of immediately provisioning an ephemeral VM. The modal pre-fills the name as + the next unused `tmp-*`, lets the user type a name, lets Up/Down choose from + the live `/profiles` list, and provisions a named persistent session with + the selected `profile_id`. ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui` (27 tests), including +- Unit/contract: `cargo test -p capsem-tui` (29 tests), including stopped-session resume prompt, grey tab, Enter-to-resume coverage, and the - far-left `help: alt+s` status-bar hint. -- TUI latency/provider: `cargo test -p capsem-tui` (27 tests), including - token reuse and raw local latency preservation coverage. + far-left `help: alt+s` status-bar hint, plus the create modal profile/name + flow. +- TUI latency/provider: `cargo test -p capsem-tui` (29 tests), including + token reuse, live profile-list refresh, and raw local latency preservation + coverage. - Process IPC: `cargo test -p capsem-process` (120 tests), including `connection_teardown_aborts_writer_and_lifecycle_tasks`. - Service/core/logger hot paths: `cargo test -p capsem-service`, @@ -179,7 +186,8 @@ - Gateway wiring: `GatewayProvider::load_async` authenticated HTTP mock test plus live local snapshot through the installed gateway. - Service actions: confirmed action key tests plus authenticated mock gateway - tests for successful stop and surfaced service error bodies. + tests for successful stop, named profile create, and surfaced service error + bodies. - Terminal wiring: `TerminalSurface` output, xterm color/style preservation, adjacent output coalescing, and key-encoding tests. - MCP wiring: `capsem_terminal_snapshot` router registration and rendering From fb98b2d1001214666e1682c56e81c91d5d069566 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 08:25:07 -0400 Subject: [PATCH 24/35] fix: add tui fork flow --- CHANGELOG.md | 4 + crates/capsem-tui/src/app.rs | 103 +++++++++++++++++++++- crates/capsem-tui/src/gateway_provider.rs | 17 ++++ crates/capsem-tui/src/tests.rs | 82 +++++++++++++++++ crates/capsem-tui/src/ui.rs | 38 ++++++-- sprints/tui-control/MASTER.md | 14 ++- sprints/tui-control/tracker.md | 24 +++-- 7 files changed, 263 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b588f32f..198f183c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a profile-aware `capsem-tui` new-session dialog with an editable prefilled `tmp-*` session name and live profile selection before provisioning. +- Added a `capsem-tui` fork dialog on `Alt+f` that asks for a fork name and + sends the request through the installed gateway. - Added `capsem-tui` to local install/package payloads so the TUI is available from `~/.capsem/bin/capsem-tui` after installation. - Added `capsem_terminal_snapshot` to the Capsem MCP server so agents can @@ -106,6 +108,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a far-left `help: alt+s` suspend hint to the `capsem-tui` status bar so the primary lifecycle shortcut is visible without reopening the full help overlay. +- Clarified the `capsem-tui` help overlay so `Alt+s` is labeled as save, + `Alt+o` as sessions/status, and `Alt+f` as fork. - Added Linux KVM doctor coverage that creates and resolves symlinks under `/tmp`, keeping link-heavy cache/tool probes off the VirtioFS workspace while diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index 083d3e97..a13e1f29 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -18,12 +18,14 @@ pub enum AppOverlay { Stats, Home, Create, + Fork, Confirm, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum ControlAction { CreateSession { name: String, profile_id: String }, + Fork { id: String, name: String }, Resume { name: String }, Suspend { id: String }, Stop { id: String }, @@ -34,8 +36,9 @@ impl ControlAction { pub const fn label(&self) -> &'static str { match self { Self::CreateSession { .. } => "create", + Self::Fork { .. } => "fork", Self::Resume { .. } => "resume", - Self::Suspend { .. } => "suspend", + Self::Suspend { .. } => "save", Self::Stop { .. } => "stop", Self::Delete { .. } => "delete", } @@ -44,6 +47,7 @@ impl ControlAction { pub fn target(&self) -> &str { match self { Self::CreateSession { name, .. } => name, + Self::Fork { name, .. } => name, Self::Resume { name } | Self::Suspend { id: name } | Self::Stop { id: name } @@ -59,6 +63,7 @@ pub struct App { overlay: AppOverlay, pending_action: Option, create_draft: Option, + fork_draft: Option, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -67,6 +72,12 @@ pub struct CreateDraft { pub selected_profile: usize, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ForkDraft { + pub source_id: String, + pub name: String, +} + impl App { pub fn new(state: AppState) -> Self { let active_index = state @@ -80,6 +91,7 @@ impl App { overlay: AppOverlay::None, pending_action: None, create_draft: None, + fork_draft: None, } } @@ -99,6 +111,10 @@ impl App { self.create_draft.as_ref() } + pub fn fork_draft(&self) -> Option<&ForkDraft> { + self.fork_draft.as_ref() + } + pub fn replace_state(&mut self, mut state: AppState) { state.service.control_message = self.state.service.control_message.clone(); let previous_active_id = self.state.active_session_id.clone(); @@ -132,6 +148,9 @@ impl App { if self.overlay == AppOverlay::Create { return self.handle_create_key(key); } + if self.overlay == AppOverlay::Fork { + return self.handle_fork_key(key); + } if self.handle_overlay_key(key) { return AppAction::Consumed; } @@ -145,6 +164,11 @@ impl App { self.open_create(); return AppAction::Consumed; } + if is_fork_key(key) { + if self.open_fork() { + return AppAction::Consumed; + } + } if let Some(action) = self.control_action_for_key(key) { self.pending_action = Some(action); self.overlay = AppOverlay::Confirm; @@ -222,6 +246,7 @@ impl App { }; self.pending_action = None; self.create_draft = None; + self.fork_draft = None; true } @@ -286,6 +311,7 @@ impl App { fn open_create(&mut self) { self.pending_action = None; + self.fork_draft = None; self.create_draft = Some(CreateDraft { name: next_tmp_name(&self.state), selected_profile: default_profile_index(&self.state), @@ -293,6 +319,20 @@ impl App { self.overlay = AppOverlay::Create; } + fn open_fork(&mut self) -> bool { + let Some(source_id) = self.active_id() else { + return false; + }; + self.pending_action = None; + self.create_draft = None; + self.fork_draft = Some(ForkDraft { + name: next_fork_name(&self.state, &source_id), + source_id, + }); + self.overlay = AppOverlay::Fork; + true + } + fn handle_create_key(&mut self, key: KeyEvent) -> AppAction { match key.code { KeyCode::Esc => { @@ -347,6 +387,49 @@ impl App { _ => AppAction::Consumed, } } + + fn handle_fork_key(&mut self, key: KeyEvent) -> AppAction { + match key.code { + KeyCode::Esc => { + self.fork_draft = None; + self.overlay = AppOverlay::None; + AppAction::Consumed + } + KeyCode::Enter => { + let Some(draft) = self.fork_draft.clone() else { + self.overlay = AppOverlay::None; + return AppAction::Consumed; + }; + let name = draft.name.trim().to_string(); + if name.is_empty() { + return AppAction::Consumed; + } + self.fork_draft = None; + self.overlay = AppOverlay::None; + AppAction::Invoke(ControlAction::Fork { + id: draft.source_id, + name, + }) + } + KeyCode::Backspace => { + if let Some(draft) = &mut self.fork_draft { + draft.name.pop(); + } + AppAction::Consumed + } + KeyCode::Char(ch) + if !key.modifiers.intersects( + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, + ) => + { + if let Some(draft) = &mut self.fork_draft { + draft.name.push(ch); + } + AppAction::Consumed + } + _ => AppAction::Consumed, + } + } } fn is_exit_key(key: KeyEvent) -> bool { @@ -368,6 +451,10 @@ fn is_new_key(key: KeyEvent) -> bool { is_alt_key(key.modifiers) && matches!(key.code, KeyCode::Char('n' | 'N')) } +fn is_fork_key(key: KeyEvent) -> bool { + is_alt_key(key.modifiers) && matches!(key.code, KeyCode::Char('f' | 'F')) +} + fn is_alt_key(modifiers: KeyModifiers) -> bool { modifiers.contains(KeyModifiers::ALT) } @@ -399,6 +486,20 @@ fn next_tmp_name(state: &AppState) -> String { "tmp".to_string() } +fn next_fork_name(state: &AppState, source_id: &str) -> String { + let base = format!("{source_id}-fork"); + if state.sessions.iter().all(|session| session.id != base) { + return base; + } + for index in 2..1000 { + let candidate = format!("{base}-{index}"); + if state.sessions.iter().all(|session| session.id != candidate) { + return candidate; + } + } + base +} + fn select_index(key: KeyEvent) -> Option { if !is_alt_key(key.modifiers) { return None; diff --git a/crates/capsem-tui/src/gateway_provider.rs b/crates/capsem-tui/src/gateway_provider.rs index 1e989e8a..608b13a6 100644 --- a/crates/capsem-tui/src/gateway_provider.rs +++ b/crates/capsem-tui/src/gateway_provider.rs @@ -351,6 +351,23 @@ async fn invoke_action( message: format!("created {id}"), }) } + ControlAction::Fork { id, name } => { + let response = client + .post(join_url(base_url, &["fork", id])?) + .bearer_auth(token) + .json(&serde_json::json!({ "name": name })) + .send() + .await + .with_context(|| format!("fork capsem session {id}"))?; + let body = response_json(response).await?; + let fork_name = body + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(name); + Ok(ActionOutcome { + message: format!("forked {fork_name}"), + }) + } ControlAction::Resume { name } => { post_empty(client, base_url, token, &["resume", name]).await?; Ok(ActionOutcome { diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 5943cc97..12bb16b4 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -230,6 +230,49 @@ fn create_overlay_selects_profile_and_edits_prefilled_name() { ); } +#[test] +fn help_lists_save_sessions_status_and_fork_shortcuts() { + let mut app = App::new(fixture_state()); + app.handle_key(key(KeyCode::Char('/'), KeyModifiers::ALT)); + + let snapshot = render_app_snapshot(&app, 100, 24).expect("render help"); + + assert!(snapshot.contains("Alt+s save")); + assert!(snapshot.contains("Alt+o sessions/status")); + assert!(snapshot.contains("Alt+f fork")); +} + +#[test] +fn fork_overlay_asks_for_name_and_invokes_fork_action() { + let mut app = App::new(fixture_state()); + + assert_eq!( + app.handle_key(key(KeyCode::Char('f'), KeyModifiers::ALT)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::Fork); + let snapshot = render_app_snapshot(&app, 100, 24).expect("render fork dialog"); + assert!(snapshot.contains("fork session")); + assert!(snapshot.contains("source")); + assert!(snapshot.contains("profile-v2")); + assert!(snapshot.contains("profile-v2-fork")); + + for ch in ['-', 'c', 'o', 'p', 'y'] { + assert_eq!( + app.handle_key(key(KeyCode::Char(ch), KeyModifiers::NONE)), + AppAction::Consumed + ); + } + + assert_eq!( + app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE)), + AppAction::Invoke(ControlAction::Fork { + id: "profile-v2".to_string(), + name: "profile-v2-fork-copy".to_string() + }) + ); +} + #[test] fn refresh_preserves_active_session_when_it_still_exists() { let mut app = App::new(fixture_state()); @@ -618,6 +661,45 @@ async fn gateway_provider_invokes_named_profile_create_over_authenticated_gatewa server.await.expect("server task"); } +#[tokio::test] +async fn gateway_provider_invokes_fork_over_authenticated_gateway() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test gateway"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let request = read_http_request(&mut stream).await; + if request.contains("GET /token ") { + write_json_response(&mut stream, r#"{"token":"test-token"}"#).await; + } else { + assert!( + request.contains("POST /fork/profile-v2 "), + "unexpected request: {request:?}" + ); + assert!(request.contains(r#""name":"profile-v2-fork-copy""#)); + write_json_response( + &mut stream, + r#"{"name":"profile-v2-fork-copy","size_bytes":1024}"#, + ) + .await; + } + } + }); + + let outcome = GatewayProvider::new(format!("http://{addr}")) + .invoke_async(&ControlAction::Fork { + id: "profile-v2".to_string(), + name: "profile-v2-fork-copy".to_string(), + }) + .await + .expect("invoke fork"); + + assert_eq!(outcome.message, "forked profile-v2-fork-copy"); + server.await.expect("server task"); +} + #[tokio::test] async fn gateway_provider_surfaces_action_error_body() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index e3fba930..21b891a5 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -7,7 +7,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use ratatui::{Frame, Terminal}; -use crate::app::{App, AppOverlay, ControlAction, CreateDraft}; +use crate::app::{App, AppOverlay, ControlAction, CreateDraft, ForkDraft}; use crate::model::{AppState, ServiceStatus, SessionLifecycle, SessionSummary}; use crate::terminal::{TerminalColor, TerminalLine, TerminalStyle, TerminalSurface}; @@ -30,7 +30,7 @@ pub fn render_with_terminal( state: &AppState, terminal: Option<&TerminalSurface>, ) { - render_layout(frame, state, terminal, AppOverlay::None, None, None); + render_layout(frame, state, terminal, AppOverlay::None, None, None, None); } pub fn render_app(frame: &mut Frame<'_>, app: &App, terminal: Option<&TerminalSurface>) { @@ -41,6 +41,7 @@ pub fn render_app(frame: &mut Frame<'_>, app: &App, terminal: Option<&TerminalSu app.overlay(), app.pending_action(), app.create_draft(), + app.fork_draft(), ); } @@ -51,6 +52,7 @@ fn render_layout( overlay: AppOverlay, pending_action: Option<&ControlAction>, create_draft: Option<&CreateDraft>, + fork_draft: Option<&ForkDraft>, ) { let root = frame.area(); let chunks = Layout::default() @@ -67,6 +69,7 @@ fn render_layout( overlay, pending_action, create_draft, + fork_draft, ); } @@ -300,6 +303,7 @@ fn render_overlay( overlay: AppOverlay, pending_action: Option<&ControlAction>, create_draft: Option<&CreateDraft>, + fork_draft: Option<&ForkDraft>, ) { if overlay == AppOverlay::None { return; @@ -311,6 +315,7 @@ fn render_overlay( AppOverlay::Stats => " stats ", AppOverlay::Home => " sessions ", AppOverlay::Create => " new session ", + AppOverlay::Fork => " fork session ", AppOverlay::Confirm => " confirm ", AppOverlay::None => "", }; @@ -326,6 +331,7 @@ fn render_overlay( AppOverlay::Stats => stats_lines(state), AppOverlay::Home => home_lines(state), AppOverlay::Create => create_lines(state, create_draft), + AppOverlay::Fork => fork_lines(state, fork_draft), AppOverlay::Confirm => confirm_lines(pending_action), AppOverlay::None => Vec::new(), }; @@ -358,6 +364,7 @@ fn overlay_height(state: &AppState, overlay: AppOverlay) -> u16 { AppOverlay::Create => (state.profiles.len() as u16) .saturating_add(8) .clamp(10, 18), + AppOverlay::Fork => 8, AppOverlay::Confirm => 6, AppOverlay::None => 0, } @@ -368,9 +375,10 @@ fn help_lines() -> Vec> { overlay_title("keys"), overlay_line("Alt+Left/Right switch sessions"), overlay_line("Alt+1..9 jumps to a session"), - overlay_line("Alt+n new Alt+r resume Alt+s suspend"), - overlay_line("Alt+t stop Alt+d delete Alt+q quit"), - overlay_line("Alt+? help Alt+i stats Alt+o sessions"), + overlay_line("Alt+n new Alt+f fork Alt+s save"), + overlay_line("Alt+r resume Alt+t stop Alt+d delete"), + overlay_line("Alt+? help Alt+i stats Alt+o sessions/status"), + overlay_line("Alt+q quit"), overlay_line("Alt+/ also opens help when the terminal sends slash"), overlay_line("plain q, Ctrl-C, and shell keys pass through"), ] @@ -424,6 +432,26 @@ fn create_lines(state: &AppState, draft: Option<&CreateDraft>) -> Vec) -> Vec> { + let Some(session) = state.active_session() else { + return vec![ + overlay_title("fork session"), + overlay_line("no active session"), + ]; + }; + let name = draft + .map(|draft| draft.name.as_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(" "); + vec![ + overlay_title("fork session"), + overlay_pair("source", &session.id), + overlay_pair("name", name), + overlay_line("type to edit; Backspace deletes"), + overlay_line("Enter forks; Esc cancels"), + ] +} + fn stats_lines(state: &AppState) -> Vec> { let Some(session) = state.active_session() else { return vec![overlay_title("stats"), overlay_line("no active session")]; diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index cab98083..061683f1 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -64,7 +64,7 @@ attention markers. - Added a typed app controller for session switching. - Kept plain `q` and Ctrl-C available for the agent/terminal stream. The TUI shell owns Alt chords: `Alt+Left/Right`, `Alt+1..9`, `Alt+n/r/s/t/d`, - `Alt+q`, `Alt+?`, `Alt+i`, and `Alt+o`. + `Alt+f`, `Alt+q`, `Alt+?`, `Alt+i`, and `Alt+o`. - Added `just dev-tui` for direct local playback. ## T04-T05 Closeout @@ -131,6 +131,10 @@ attention markers. new-session modal. The flow pre-fills the next unused `tmp-*` name, supports typing/backspace, lets Up/Down choose a live `/profiles` entry, and sends a named persistent `/provision` request with the selected `profile_id`. +- Added `Alt+f` as a fork modal. The flow pre-fills the next unused + `-fork` name, supports typing/backspace, and sends authenticated + `POST /fork/{id}` with the chosen `name`. Help now explicitly lists + `Alt+s save`, `Alt+o sessions/status`, and `Alt+f fork`. ## Testing Gate @@ -149,11 +153,13 @@ attention markers. `/status` p95 0.223ms. Concurrent boot pressure remains a separate follow-up because endpoint speed should not depend on parallel provisioning setup. - Regression: `cargo test -p capsem-tui` covers stopped-session prompt, - greyed inactive tab tone, and Enter-to-resume behavior. Live snapshot against - the installed stopped `tui-proof-*` sessions shows the prompt instead of a - blank pane. + greyed inactive tab tone, Enter-to-resume behavior, and the named fork + modal/action path. Live snapshot against the installed stopped + `tui-proof-*` sessions shows the prompt instead of a blank pane. - UI polish: `cargo test -p capsem-tui` and snapshot output cover the far-left `help: alt+s` status-bar hint. - New-session regression: `cargo test -p capsem-tui` covers create-modal rendering, profile selection, name editing, and authenticated named-profile provision request payloads. +- Fork regression: `cargo test -p capsem-tui` covers fork-modal rendering, + name editing, help discoverability, and authenticated gateway fork payloads. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 24b0ccc5..264cb722 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -35,6 +35,7 @@ - [x] Add `capsem_terminal_snapshot` MCP tool for session terminal inspection. - [x] Add confirmed create/resume/suspend/stop/delete actions through the installed HTTP gateway. +- [x] Add named fork action through the installed HTTP gateway. - [x] Run live installed-gateway empty-service snapshot. - [x] Run live two-session terminal proof. - [x] Commit functional milestone. @@ -77,8 +78,9 @@ bitmap screenshot. It is enough for agent debugging and works through the existing service log contract. - Safe service actions are now active behind a confirmation overlay. `Alt+n` - creates an ephemeral session, `Alt+r` resumes stopped/suspended sessions, - `Alt+s` suspends the active session, `Alt+t` stops it, and `Alt+d` deletes it. + opens the new-session dialog, `Alt+f` opens the fork dialog, `Alt+r` resumes + stopped/suspended sessions, `Alt+s` saves/suspends the active session, + `Alt+t` stops it, and `Alt+d` deletes it. Action calls run on a background worker so long suspend/stop/provision paths do not freeze terminal rendering. @@ -159,16 +161,20 @@ the next unused `tmp-*`, lets the user type a name, lets Up/Down choose from the live `/profiles` list, and provisions a named persistent session with the selected `profile_id`. +- Fork flow correction: `Alt+f` now opens a `fork session` modal, pre-fills + the next unused `-fork` name, lets the user type/backspace, and sends + `POST /fork/{id}` with the chosen `name`. Full help now calls out + `Alt+s save`, `Alt+o sessions/status`, and `Alt+f fork`. ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui` (29 tests), including +- Unit/contract: `cargo test -p capsem-tui` (32 tests), including stopped-session resume prompt, grey tab, Enter-to-resume coverage, and the far-left `help: alt+s` status-bar hint, plus the create modal profile/name - flow. -- TUI latency/provider: `cargo test -p capsem-tui` (29 tests), including - token reuse, live profile-list refresh, and raw local latency preservation - coverage. + flow and named fork modal/action coverage. +- TUI latency/provider: `cargo test -p capsem-tui` (32 tests), including + token reuse, live profile-list refresh, named fork request payloads, and raw + local latency preservation coverage. - Process IPC: `cargo test -p capsem-process` (120 tests), including `connection_teardown_aborts_writer_and_lifecycle_tasks`. - Service/core/logger hot paths: `cargo test -p capsem-service`, @@ -186,8 +192,8 @@ - Gateway wiring: `GatewayProvider::load_async` authenticated HTTP mock test plus live local snapshot through the installed gateway. - Service actions: confirmed action key tests plus authenticated mock gateway - tests for successful stop, named profile create, and surfaced service error - bodies. + tests for successful stop, named profile create, named fork, and surfaced + service error bodies. - Terminal wiring: `TerminalSurface` output, xterm color/style preservation, adjacent output coalescing, and key-encoding tests. - MCP wiring: `capsem_terminal_snapshot` router registration and rendering From e3d0312f00ac36c46abfa9ccc42483788790483e Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 08:38:38 -0400 Subject: [PATCH 25/35] fix: polish tui controls and overlays --- CHANGELOG.md | 13 +- crates/capsem-tui/src/app.rs | 18 +- crates/capsem-tui/src/gateway_provider.rs | 6 + crates/capsem-tui/src/tests.rs | 139 +++++++++++++++- crates/capsem-tui/src/ui.rs | 193 +++++++++++++++++----- justfile | 5 +- sprints/tui-control/MASTER.md | 27 ++- sprints/tui-control/tracker.md | 57 ++++--- 8 files changed, 374 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 198f183c..bd6c5487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 provisioning. - Added a `capsem-tui` fork dialog on `Alt+f` that asks for a fork name and sends the request through the installed gateway. +- Added `Alt+c` checkpoint/save as an explicit `capsem-tui` action, leaving + `Alt+s` to mean suspend. - Added `capsem-tui` to local install/package payloads so the TUI is available from `~/.capsem/bin/capsem-tui` after installation. - Added `capsem_terminal_snapshot` to the Capsem MCP server so agents can @@ -105,11 +107,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added Linux host doctor smoke probes for `KVM_GET_API_VERSION` and `/dev/vhost-vsock` openability so bootstrap verifies usable KVM devices, not just filesystem permissions. -- Added a far-left `help: alt+s` suspend hint to the `capsem-tui` status bar - so the primary lifecycle shortcut is visible without reopening the full help - overlay. -- Clarified the `capsem-tui` help overlay so `Alt+s` is labeled as save, - `Alt+o` as sessions/status, and `Alt+f` as fork. +- Added structured `capsem-tui` help and session-list tables, an explicit + `Alt+l` sessions overlay, and clearer `Alt+i` session info. +- Added focused-field highlighting to `capsem-tui` create and fork dialogs so + the active input and selected profile are visible. +- Changed the far-left `capsem-tui` status hint to `help: alt+/` so it no + longer conflicts with `Alt+s` suspend. - Added Linux KVM doctor coverage that creates and resolves symlinks under `/tmp`, keeping link-heavy cache/tool probes off the VirtioFS workspace while diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index a13e1f29..5544462c 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -27,6 +27,7 @@ pub enum ControlAction { CreateSession { name: String, profile_id: String }, Fork { id: String, name: String }, Resume { name: String }, + Checkpoint { id: String }, Suspend { id: String }, Stop { id: String }, Delete { id: String }, @@ -38,7 +39,8 @@ impl ControlAction { Self::CreateSession { .. } => "create", Self::Fork { .. } => "fork", Self::Resume { .. } => "resume", - Self::Suspend { .. } => "save", + Self::Checkpoint { .. } => "checkpoint", + Self::Suspend { .. } => "suspend", Self::Stop { .. } => "stop", Self::Delete { .. } => "delete", } @@ -49,6 +51,7 @@ impl ControlAction { Self::CreateSession { name, .. } => name, Self::Fork { name, .. } => name, Self::Resume { name } + | Self::Checkpoint { id: name } | Self::Suspend { id: name } | Self::Stop { id: name } | Self::Delete { id: name } => name, @@ -236,7 +239,7 @@ impl App { let next = match key.code { KeyCode::Char('?' | '/') => AppOverlay::Help, KeyCode::Char('i' | 'I') => AppOverlay::Stats, - KeyCode::Char('o' | 'O') => AppOverlay::Home, + KeyCode::Char('l' | 'L' | 'o' | 'O') => AppOverlay::Home, _ => return false, }; self.overlay = if self.overlay == next { @@ -273,6 +276,7 @@ impl App { } match key.code { KeyCode::Char('r' | 'R') => self.active_resume_action(), + KeyCode::Char('c' | 'C') => self.active_checkpoint_action(), KeyCode::Char('s' | 'S') => self.active_suspend_action(), KeyCode::Char('t' | 'T') => self.active_id().map(|id| ControlAction::Stop { id }), KeyCode::Char('d' | 'D') => self.active_id().map(|id| ControlAction::Delete { id }), @@ -293,6 +297,16 @@ impl App { }) } + fn active_checkpoint_action(&self) -> Option { + let session = self.state.active_session()?; + if !session.persistent || !matches!(session.lifecycle, SessionLifecycle::Working) { + return None; + } + Some(ControlAction::Checkpoint { + id: session.id.clone(), + }) + } + fn active_suspend_action(&self) -> Option { let session = self.state.active_session()?; if !session.persistent || !matches!(session.lifecycle, SessionLifecycle::Working) { diff --git a/crates/capsem-tui/src/gateway_provider.rs b/crates/capsem-tui/src/gateway_provider.rs index 608b13a6..994392b9 100644 --- a/crates/capsem-tui/src/gateway_provider.rs +++ b/crates/capsem-tui/src/gateway_provider.rs @@ -374,6 +374,12 @@ async fn invoke_action( message: format!("resumed {name}"), }) } + ControlAction::Checkpoint { id } => { + post_empty(client, base_url, token, &["suspend", id]).await?; + Ok(ActionOutcome { + message: format!("checkpointed {id}"), + }) + } ControlAction::Suspend { id } => { post_empty(client, base_url, token, &["suspend", id]).await?; Ok(ActionOutcome { diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 12bb16b4..857167e8 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -6,7 +6,7 @@ use crate::app::{App, AppAction, AppOverlay, ControlAction}; use crate::fixture::fixture_state; use crate::gateway_provider::{state_from_status_json_for_test, GatewayProvider}; use crate::model::{Attention, ServiceStatus, SessionLifecycle}; -use crate::ui::{render_app_snapshot, render_snapshot, render_test_buffer}; +use crate::ui::{render_app_snapshot, render_app_test_buffer, render_snapshot, render_test_buffer}; #[test] fn fixture_models_global_service_state_and_session_indicators() { @@ -28,7 +28,7 @@ fn fixture_models_global_service_state_and_session_indicators() { fn snapshot_contains_light_bar_tabs_and_active_desktop() { let snapshot = render_snapshot(&fixture_state(), 100, 24).expect("render snapshot"); - assert!(snapshot.contains("help: alt+s 18ms●")); + assert!(snapshot.contains("help: alt+/ 18ms●")); assert!(snapshot.contains("1 profile-v2")); assert!(snapshot.contains("2 linux-os!")); assert!(snapshot.contains("◷ 47m | # 38.4k | $ 0.21")); @@ -209,11 +209,26 @@ fn create_overlay_selects_profile_and_edits_prefilled_name() { assert!(snapshot.contains("tmp-1")); assert!(snapshot.contains("corp-default")); assert!(snapshot.contains("linux-builder")); + assert!(snapshot.contains("active input")); assert_eq!( app.handle_key(key(KeyCode::Down, KeyModifiers::NONE)), AppAction::Consumed ); + let focused = render_app_test_buffer(&app, 100, 24).expect("render focused create dialog"); + let (name_x, name_y) = find_cell(&focused, "tmp-1"); + assert_eq!(buffer_cell(&focused, name_x, name_y).bg, selected_bg()); + let (profile_x, profile_y) = find_cell(&focused, "linux-builder"); + assert_eq!( + buffer_cell(&focused, profile_x, profile_y).bg, + selected_bg() + ); + assert!( + buffer_cell(&focused, profile_x, profile_y) + .modifier + .contains(Modifier::BOLD), + "selected profile row should be visually highlighted" + ); for ch in ['-', 'p', 'r', 'o', 'o', 'f'] { assert_eq!( app.handle_key(key(KeyCode::Char(ch), KeyModifiers::NONE)), @@ -237,8 +252,16 @@ fn help_lists_save_sessions_status_and_fork_shortcuts() { let snapshot = render_app_snapshot(&app, 100, 24).expect("render help"); - assert!(snapshot.contains("Alt+s save")); - assert!(snapshot.contains("Alt+o sessions/status")); + assert!(snapshot.contains("Key")); + assert!(snapshot.contains("Action")); + assert!(snapshot.contains("Alt+s")); + assert!(snapshot.contains("suspend")); + assert!(snapshot.contains("Alt+c")); + assert!(snapshot.contains("checkpoint")); + assert!(snapshot.contains("Alt+l")); + assert!(snapshot.contains("sessions")); + assert!(snapshot.contains("Alt+i")); + assert!(snapshot.contains("session info")); assert!(snapshot.contains("Alt+f fork")); } @@ -256,6 +279,7 @@ fn fork_overlay_asks_for_name_and_invokes_fork_action() { assert!(snapshot.contains("source")); assert!(snapshot.contains("profile-v2")); assert!(snapshot.contains("profile-v2-fork")); + assert!(snapshot.contains("active input")); for ch in ['-', 'c', 'o', 'p', 'y'] { assert_eq!( @@ -273,6 +297,28 @@ fn fork_overlay_asks_for_name_and_invokes_fork_action() { ); } +#[test] +fn alt_l_lists_sessions_as_table_with_key_fields() { + let mut app = App::new(fixture_state()); + + assert_eq!( + app.handle_key(key(KeyCode::Char('l'), KeyModifiers::ALT)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::Home); + + let snapshot = render_app_snapshot(&app, 120, 30).expect("render session list"); + assert!(snapshot.contains("Name")); + assert!(snapshot.contains("Profile")); + assert!(snapshot.contains("State")); + assert!(snapshot.contains("Time")); + assert!(snapshot.contains("Tokens")); + assert!(snapshot.contains("Cost")); + assert!(snapshot.contains("Profile V2")); + assert!(snapshot.contains("corp-default")); + assert!(snapshot.contains("linux-builder")); +} + #[test] fn refresh_preserves_active_session_when_it_still_exists() { let mut app = App::new(fixture_state()); @@ -318,6 +364,16 @@ fn function_keys_toggle_hidden_overlays() { AppAction::Consumed ); assert_eq!(app.overlay(), AppOverlay::None); + assert_eq!( + app.handle_key(key(KeyCode::Char('l'), KeyModifiers::ALT)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::Home); + assert_eq!( + app.handle_key(key(KeyCode::Char('l'), KeyModifiers::ALT)), + AppAction::Consumed + ); + assert_eq!(app.overlay(), AppOverlay::None); } #[test] @@ -436,6 +492,25 @@ fn suspend_action_requires_persistent_running_session() { ); } +#[test] +fn checkpoint_action_is_alt_c_and_uses_checkpoint_label() { + let mut app = App::new(fixture_state()); + assert_eq!( + app.handle_key(key(KeyCode::Char('c'), KeyModifiers::ALT)), + AppAction::Consumed + ); + assert_eq!( + app.pending_action(), + Some(&ControlAction::Checkpoint { + id: "profile-v2".to_string() + }) + ); + + let snapshot = render_app_snapshot(&app, 100, 24).expect("render checkpoint confirm"); + assert!(snapshot.contains("checkpoint")); + assert!(snapshot.contains("profile-v2")); +} + #[test] fn stats_overlay_renders_on_demand_without_persistent_help() { let mut app = App::new(fixture_state()); @@ -443,7 +518,9 @@ fn stats_overlay_renders_on_demand_without_persistent_help() { let snapshot = render_app_snapshot(&app, 100, 24).expect("render app snapshot"); - assert!(snapshot.contains("stats")); + assert!(snapshot.contains("session info")); + assert!(snapshot.contains("Field")); + assert!(snapshot.contains("Value")); assert!(snapshot.contains("profile-v2")); assert!(snapshot.contains("tokens")); assert!( @@ -700,6 +777,39 @@ async fn gateway_provider_invokes_fork_over_authenticated_gateway() { server.await.expect("server task"); } +#[tokio::test] +async fn gateway_provider_invokes_checkpoint_over_suspend_endpoint() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test gateway"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let request = read_http_request(&mut stream).await; + if request.contains("GET /token ") { + write_json_response(&mut stream, r#"{"token":"test-token"}"#).await; + } else { + assert!( + request.contains("POST /suspend/vm-1 "), + "unexpected request: {request:?}" + ); + write_json_response(&mut stream, r#"{"success":true}"#).await; + } + } + }); + + let outcome = GatewayProvider::new(format!("http://{addr}")) + .invoke_async(&ControlAction::Checkpoint { + id: "vm-1".to_string(), + }) + .await + .expect("invoke checkpoint"); + + assert_eq!(outcome.message, "checkpointed vm-1"); + server.await.expect("server task"); +} + #[tokio::test] async fn gateway_provider_surfaces_action_error_body() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") @@ -754,6 +864,21 @@ fn find_cell_x(buffer: &ratatui::buffer::Buffer, row: u16, needle: &str) -> u16 line[..byte_index].chars().count() as u16 } +fn find_cell(buffer: &ratatui::buffer::Buffer, needle: &str) -> (u16, u16) { + let width = buffer.area.width as usize; + for y in 0..buffer.area.height { + let row_start = y as usize * width; + let line = buffer.content()[row_start..row_start + width] + .iter() + .map(|cell| cell.symbol()) + .collect::(); + if let Some(byte_index) = line.find(needle) { + return (line[..byte_index].chars().count() as u16, y); + } + } + panic!("{needle:?} in rendered buffer"); +} + fn buffer_cell(buffer: &ratatui::buffer::Buffer, x: u16, y: u16) -> &ratatui::buffer::Cell { let width = buffer.area.width as usize; &buffer.content()[y as usize * width + x as usize] @@ -771,6 +896,10 @@ fn grey() -> Color { Color::Rgb(127, 137, 180) } +fn selected_bg() -> Color { + Color::Rgb(49, 50, 68) +} + async fn read_http_request(stream: &mut tokio::net::TcpStream) -> String { let mut request = Vec::new(); let mut buffer = [0_u8; 256]; diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 21b891a5..6558e89c 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -20,6 +20,7 @@ const ONLINE: Color = Color::Rgb(166, 227, 161); const ACTIVE: Color = Color::Rgb(137, 180, 250); const ATTENTION: Color = Color::Rgb(249, 226, 175); const BAD: Color = Color::Rgb(243, 139, 168); +const SELECTED_BG: Color = Color::Rgb(49, 50, 68); pub fn render(frame: &mut Frame<'_>, state: &AppState) { render_with_terminal(frame, state, None); @@ -100,6 +101,14 @@ pub(crate) fn render_test_buffer(state: &AppState, width: u16, height: u16) -> R render_buffer(state, width, height) } +#[cfg(test)] +pub(crate) fn render_app_test_buffer(app: &App, width: u16, height: u16) -> Result { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|frame| render_app(frame, app, None))?; + Ok(terminal.backend().buffer().clone()) +} + fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { let service = &state.service; let active_index = state @@ -111,7 +120,7 @@ fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { frame.render_widget(Paragraph::new("").style(base), area); let mut left = vec![ - Span::styled("help: alt+s ", muted_style()), + Span::styled("help: alt+/ ", muted_style()), Span::styled(format!("{:>4}ms", service.latency.as_millis()), base), Span::styled( service_dot(service.status), @@ -312,7 +321,7 @@ fn render_overlay( frame.render_widget(Clear, popup); let title = match overlay { AppOverlay::Help => " help ", - AppOverlay::Stats => " stats ", + AppOverlay::Stats => " session info ", AppOverlay::Home => " sessions ", AppOverlay::Create => " new session ", AppOverlay::Fork => " fork session ", @@ -358,12 +367,12 @@ fn centered_rect(area: Rect, width_percent: u16, height: u16) -> Rect { fn overlay_height(state: &AppState, overlay: AppOverlay) -> u16 { match overlay { - AppOverlay::Help => 10, - AppOverlay::Stats => 10, + AppOverlay::Help => 17, + AppOverlay::Stats => 12, AppOverlay::Home => (state.sessions.len() as u16).saturating_add(5).clamp(7, 16), AppOverlay::Create => (state.profiles.len() as u16) - .saturating_add(8) - .clamp(10, 18), + .saturating_add(9) + .clamp(11, 18), AppOverlay::Fork => 8, AppOverlay::Confirm => 6, AppOverlay::None => 0, @@ -373,14 +382,20 @@ fn overlay_height(state: &AppState, overlay: AppOverlay) -> u16 { fn help_lines() -> Vec> { vec![ overlay_title("keys"), - overlay_line("Alt+Left/Right switch sessions"), - overlay_line("Alt+1..9 jumps to a session"), - overlay_line("Alt+n new Alt+f fork Alt+s save"), - overlay_line("Alt+r resume Alt+t stop Alt+d delete"), - overlay_line("Alt+? help Alt+i stats Alt+o sessions/status"), - overlay_line("Alt+q quit"), - overlay_line("Alt+/ also opens help when the terminal sends slash"), - overlay_line("plain q, Ctrl-C, and shell keys pass through"), + table_header(&["Key", "Action", "Scope", "Note"]), + help_row("Alt+Left", "previous", "global", "switch session"), + help_row("Alt+Right", "next", "global", "switch session"), + help_row("Alt+1..9", "jump", "global", "select by tab number"), + help_row("Alt+l", "sessions", "global", "list sessions and status"), + help_row("Alt+i", "session info", "session", "active VM details"), + help_row("Alt+n", "new", "global", "create from profile"), + help_row("Alt+f", "fork", "session", "fork active VM"), + help_row("Alt+s", "suspend", "session", "warm stop active VM"), + help_row("Alt+c", "checkpoint", "session", "save/checkpoint VM"), + help_row("Alt+r", "resume", "session", "resume inactive VM"), + help_row("Alt+t", "stop", "session", "stop active VM"), + help_row("Alt+d", "delete", "session", "delete active VM"), + help_row("Alt+q", "quit", "app", "plain q passes through"), ] } @@ -402,16 +417,19 @@ fn create_lines(state: &AppState, draft: Option<&CreateDraft>) -> Vec) -> Vec) -> Vec> vec![ overlay_title("fork session"), overlay_pair("source", &session.id), - overlay_pair("name", name), - overlay_line("type to edit; Backspace deletes"), + focus_pair("name", name), + overlay_line("active input: name; type to edit; Backspace deletes"), overlay_line("Enter forks; Esc cancels"), ] } fn stats_lines(state: &AppState) -> Vec> { let Some(session) = state.active_session() else { - return vec![overlay_title("stats"), overlay_line("no active session")]; + return vec![ + overlay_title("session info"), + overlay_line("no active session"), + ]; }; vec![ - overlay_title("stats"), - overlay_pair("session", &session.id), - overlay_pair("profile", &session.profile), - overlay_pair("state", session.lifecycle.label()), - overlay_pair("duration", &format_duration(session.stats.duration)), - overlay_pair("tokens", &format_tokens(session.stats.tokens)), - overlay_pair( + overlay_title("session info"), + table_header(&["Field", "Value", "Note", ""]), + info_row("session", &session.id, &session.title), + info_row( + "profile", + &session.profile, + session.branch.as_deref().unwrap_or(""), + ), + info_row( + "state", + session.lifecycle.label(), + attention_summary(session), + ), + info_row("duration", &format_duration(session.stats.duration), ""), + info_row("tokens", &format_tokens(session.stats.tokens), ""), + info_row( "cost", &format!("${}", format_cost_amount(session.stats.cost_micros)), + "", ), - overlay_pair("events", &session.stats.events.to_string()), + info_row("events", &session.stats.events.to_string(), ""), + info_row("jobs", &session.stats.jobs.to_string(), ""), ] } @@ -477,19 +514,30 @@ fn home_lines(state: &AppState) -> Vec> { lines.push(overlay_line("no sessions")); return lines; } + lines.push(table_header(&[ + "#", "Name", "Profile", "State", "Time", "Tokens", "Cost", + ])); for (index, session) in state.sessions.iter().take(10).enumerate() { let active = if session.id == state.active_session_id { - "*" + "▶" } else { " " }; - lines.push(overlay_line(&format!( - "{active} {} {} {} {}", + let row = format!( + "{active} {:<2} {:<18} {:<14} {:<10} {:>6} {:>7} ${:<5}", index + 1, - truncate(&session.id, 18), + truncate(&session.title, 18), + truncate(&session.profile, 14), session.lifecycle.label(), - session.profile - ))); + format_duration(session.stats.duration), + format_tokens(session.stats.tokens), + format_cost_amount(session.stats.cost_micros), + ); + if session.id == state.active_session_id { + lines.push(focus_line(&row)); + } else { + lines.push(overlay_line(&row)); + } } lines } @@ -508,6 +556,10 @@ fn overlay_line(text: &str) -> Line<'static> { Line::from(Span::styled(text.to_string(), status_base_style())) } +fn focus_line(text: &str) -> Line<'static> { + Line::from(Span::styled(text.to_string(), focus_style())) +} + fn overlay_pair(label: &'static str, value: &str) -> Line<'static> { Line::from(vec![ Span::styled(format!("{label:>8} "), muted_style()), @@ -515,6 +567,52 @@ fn overlay_pair(label: &'static str, value: &str) -> Line<'static> { ]) } +fn focus_pair(label: &'static str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label:>8} "), muted_style()), + Span::styled(value.to_string(), focus_style()), + ]) +} + +fn table_header(columns: &[&'static str]) -> Line<'static> { + let widths = [8, 18, 14, 12, 8, 8, 8]; + let spans = columns + .iter() + .enumerate() + .map(|(index, column)| { + Span::styled( + format!( + "{column:>(); + Line::from(spans) +} + +fn help_row( + key: &'static str, + action: &'static str, + scope: &'static str, + note: &'static str, +) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!("{key} "), + status_base_style().add_modifier(Modifier::BOLD), + ), + Span::styled(format!("{action:<14}"), status_base_style()), + Span::styled(format!("{scope:<12}"), muted_style()), + Span::styled(note.to_string(), status_base_style()), + ]) +} + +fn info_row(field: &'static str, value: &str, note: impl AsRef) -> Line<'static> { + overlay_line(&format!("{field:<8} {value:<18} {}", note.as_ref())) +} + fn tab_spans(state: &AppState, active_index: usize, max_width: usize) -> Vec> { let visible = visible_tab_range(state.sessions.len(), active_index); let mut spans = Vec::new(); @@ -645,6 +743,13 @@ fn muted_style() -> Style { Style::default().fg(MUTED).bg(BAR_BG) } +fn focus_style() -> Style { + Style::default() + .fg(ATTENTION) + .bg(SELECTED_BG) + .add_modifier(Modifier::BOLD) +} + fn stats_style() -> Style { Style::default().fg(TEXT).bg(BAR_BG) } @@ -699,6 +804,18 @@ fn attention_marker(session: &SessionSummary) -> &'static str { } } +fn attention_summary(session: &SessionSummary) -> String { + if session.attention.is_empty() { + return String::new(); + } + session + .attention + .iter() + .map(|attention| attention.marker()) + .collect::>() + .join(",") +} + fn active_stats_spans(session: &SessionSummary) -> Vec> { vec![ Span::styled(" ◷ ", muted_style()), diff --git a/justfile b/justfile index b8e7b197..653f2841 100644 --- a/justfile +++ b/justfile @@ -257,8 +257,9 @@ dev-frontend: _pnpm-install # Standalone terminal control-plane shell. # App-owned controls: Alt+Left/Right switch sessions; Alt+1..9 jumps; -# Alt+n new, Alt+r resume, Alt+s suspend, Alt+t stop, Alt+d delete, Alt+q quit; -# Alt+?/Alt+/ help, Alt+i stats, Alt+o sessions. Plain q/Ctrl-C pass to the VM. +# Alt+n new, Alt+f fork, Alt+r resume, Alt+s suspend, Alt+c checkpoint, +# Alt+t stop, Alt+d delete, Alt+q quit; +# Alt+?/Alt+/ help, Alt+i session info, Alt+l sessions. Plain q/Ctrl-C pass to the VM. # Pass extra args after `--`: `just dev-tui -- --snapshot`. dev-tui *ARGS: cargo run -p capsem-tui {{ARGS}} diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 061683f1..325084d5 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -63,8 +63,8 @@ attention markers. session stats on the right. - Added a typed app controller for session switching. - Kept plain `q` and Ctrl-C available for the agent/terminal stream. The TUI - shell owns Alt chords: `Alt+Left/Right`, `Alt+1..9`, `Alt+n/r/s/t/d`, - `Alt+f`, `Alt+q`, `Alt+?`, `Alt+i`, and `Alt+o`. + shell owns Alt chords: `Alt+Left/Right`, `Alt+1..9`, + `Alt+n/f/r/s/c/t/d`, `Alt+q`, `Alt+?`, `Alt+i`, and `Alt+l`. - Added `just dev-tui` for direct local playback. ## T04-T05 Closeout @@ -125,16 +125,22 @@ attention markers. `Press Enter to resume` prompt, Enter invokes resume for the active inactive session, and the terminal WebSocket bridge disconnects instead of reconnecting to stopped VM sockets. -- Added a far-left `help: alt+s` hint before service latency/status so suspend - is discoverable without adding back the old full-width help strip. +- Added a far-left `help: alt+/` hint before service latency/status so help is + discoverable without conflicting with `Alt+s` suspend. - Corrected `Alt+n` from one-key ephemeral provisioning into a profile-aware new-session modal. The flow pre-fills the next unused `tmp-*` name, supports typing/backspace, lets Up/Down choose a live `/profiles` entry, and sends a named persistent `/provision` request with the selected `profile_id`. - Added `Alt+f` as a fork modal. The flow pre-fills the next unused `-fork` name, supports typing/backspace, and sends authenticated - `POST /fork/{id}` with the chosen `name`. Help now explicitly lists - `Alt+s save`, `Alt+o sessions/status`, and `Alt+f fork`. + `POST /fork/{id}` with the chosen `name`. +- Split `Alt+s` and `Alt+c`: `Alt+s` is suspend, while `Alt+c` is the + checkpoint/save affordance routed through the current suspend endpoint until + the service exposes a separate checkpoint-only API. +- Reworked help, session list, and session info as structured tables. `Alt+l` + is the primary session-list chord, `Alt+i` opens active-session info, and + create/fork modals now visibly highlight the active input and selected + profile row. ## Testing Gate @@ -154,12 +160,15 @@ attention markers. because endpoint speed should not depend on parallel provisioning setup. - Regression: `cargo test -p capsem-tui` covers stopped-session prompt, greyed inactive tab tone, Enter-to-resume behavior, and the named fork - modal/action path. Live snapshot against the installed stopped - `tui-proof-*` sessions shows the prompt instead of a blank pane. + modal/action path, plus `Alt+l` sessions, `Alt+i` session info, and `Alt+c` + checkpoint. Live snapshot against the installed stopped `tui-proof-*` + sessions shows the prompt instead of a blank pane. - UI polish: `cargo test -p capsem-tui` and snapshot output cover the far-left - `help: alt+s` status-bar hint. + `help: alt+/` status-bar hint and focused-field highlighting. - New-session regression: `cargo test -p capsem-tui` covers create-modal rendering, profile selection, name editing, and authenticated named-profile provision request payloads. - Fork regression: `cargo test -p capsem-tui` covers fork-modal rendering, name editing, help discoverability, and authenticated gateway fork payloads. +- Checkpoint regression: `cargo test -p capsem-tui` covers `Alt+c` confirmation + and the authenticated checkpoint request over the current suspend endpoint. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 264cb722..1028e6cc 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -14,7 +14,7 @@ - [x] Add two-session standalone fixture for local playback. - [x] Add keyboard session switching without capturing plain `q`. - [x] Add `just dev-tui` recipe. -- [x] Add hidden help, stats, and sessions overlays. +- [x] Add hidden help, session-info, and sessions overlays. - [x] Inventory existing gateway `/status` model for TUI state. - [x] Add typed gateway provider over installed HTTP gateway. - [x] Map lifecycle, attention, uptime, token, cost, job, and event counters. @@ -36,6 +36,10 @@ - [x] Add confirmed create/resume/suspend/stop/delete actions through the installed HTTP gateway. - [x] Add named fork action through the installed HTTP gateway. +- [x] Split `Alt+s` suspend from `Alt+c` checkpoint/save. +- [x] Rework help, session list, and session info into structured readable + tables. +- [x] Highlight active modal fields and selected profile rows. - [x] Run live installed-gateway empty-service snapshot. - [x] Run live two-session terminal proof. - [x] Commit functional milestone. @@ -79,8 +83,9 @@ existing service log contract. - Safe service actions are now active behind a confirmation overlay. `Alt+n` opens the new-session dialog, `Alt+f` opens the fork dialog, `Alt+r` resumes - stopped/suspended sessions, `Alt+s` saves/suspends the active session, - `Alt+t` stops it, and `Alt+d` deletes it. + stopped/suspended sessions, `Alt+s` suspends the active session, `Alt+c` + checkpoints/saves it through the current suspend endpoint, `Alt+t` stops it, + and `Alt+d` deletes it. Action calls run on a background worker so long suspend/stop/provision paths do not freeze terminal rendering. @@ -104,20 +109,21 @@ - Service latency now renders as `####ms●`, with the status dot glued to the reserved latency field so it reads as one service-health segment. - Navigation is now app-owned: `Alt+Left/Right` switches sessions and - `Alt+1..9` jumps by tab number. `Alt+n/r/s/t/d`, `Alt+q`, `Alt+?`/`Alt+/`, - `Alt+i`, and `Alt+o` cover shell actions, exit, help, stats, and session - list. + `Alt+1..9` jumps by tab number. `Alt+n/f/r/s/c/t/d`, `Alt+q`, + `Alt+?`/`Alt+/`, `Alt+i`, and `Alt+l` cover shell actions, exit, help, + session info, and session list. - Tab colors now use one semantic: selected is yellow, every other VM tab is blue. Bell/attention state keeps its text marker but no longer changes the tab color. - Interactive terminal resize now tracks the active session and geometry together, so a pure terminal resize resends the guest PTY size even when the selected VM did not change. -- Help, stats, sessions, and confirmation overlays now use Ratatui `Clear` and - bordered modal blocks instead of drawing loose text over terminal output. -- Help, stats, and sessions are real modals: `Esc` closes them, visible modals - consume normal keys, and plain VM input forwards again immediately after - close. Key-release events are ignored in the interactive loop. +- Help, session info, sessions, and confirmation overlays now use Ratatui + `Clear` and bordered modal blocks instead of drawing loose text over + terminal output. +- Help, session info, and sessions are real modals: `Esc` closes them, visible + modals consume normal keys, and plain VM input forwards again immediately + after close. Key-release events are ignored in the interactive loop. - `just dev-tui` documents the same Alt-only shell contract inline so local playback and installed usage do not drift. - MCP triage for `tui-proof-a` found no session-level failures. Host triage @@ -154,7 +160,7 @@ `Press Enter to resume`, Enter invokes resume for the active inactive session, and the terminal bridge disconnects from inactive tabs. - Discoverability polish: the persistent status segment now starts with - `help: alt+s` before service latency/status. The full command list remains + `help: alt+/` before service latency/status. The full command list remains in the help overlay. - New-session flow correction: `Alt+n` now opens a `new session` modal instead of immediately provisioning an ephemeral VM. The modal pre-fills the name as @@ -163,18 +169,22 @@ the selected `profile_id`. - Fork flow correction: `Alt+f` now opens a `fork session` modal, pre-fills the next unused `-fork` name, lets the user type/backspace, and sends - `POST /fork/{id}` with the chosen `name`. Full help now calls out - `Alt+s save`, `Alt+o sessions/status`, and `Alt+f fork`. + `POST /fork/{id}` with the chosen `name`. +- Usability correction: full help is now a structured table, `Alt+l` opens the + session list table, `Alt+i` opens session info, and create/fork modals + highlight the active input plus the selected profile row. ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui` (32 tests), including +- Unit/contract: `cargo test -p capsem-tui` (35 tests), including stopped-session resume prompt, grey tab, Enter-to-resume coverage, and the - far-left `help: alt+s` status-bar hint, plus the create modal profile/name - flow and named fork modal/action coverage. -- TUI latency/provider: `cargo test -p capsem-tui` (32 tests), including - token reuse, live profile-list refresh, named fork request payloads, and raw - local latency preservation coverage. + far-left `help: alt+/` status-bar hint, plus the create modal profile/name + flow, selected-field highlighting, named fork modal/action coverage, + `Alt+l` sessions table, `Alt+i` session info, and `Alt+c` checkpoint. +- TUI latency/provider: `cargo test -p capsem-tui` (35 tests), including + token reuse, live profile-list refresh, named fork request payloads, + checkpoint-over-suspend payloads, and raw local latency preservation + coverage. - Process IPC: `cargo test -p capsem-process` (120 tests), including `connection_teardown_aborts_writer_and_lifecycle_tasks`. - Service/core/logger hot paths: `cargo test -p capsem-service`, @@ -192,13 +202,14 @@ - Gateway wiring: `GatewayProvider::load_async` authenticated HTTP mock test plus live local snapshot through the installed gateway. - Service actions: confirmed action key tests plus authenticated mock gateway - tests for successful stop, named profile create, named fork, and surfaced - service error bodies. + tests for successful stop, named profile create, named fork, checkpoint, and + surfaced service error bodies. - Terminal wiring: `TerminalSurface` output, xterm color/style preservation, adjacent output coalescing, and key-encoding tests. - MCP wiring: `capsem_terminal_snapshot` router registration and rendering tests. -- Overlay wiring: function-key state tests and stats overlay render test. +- Overlay wiring: function-key state tests and session-info overlay render + test. - Adversarial: malformed gateway status mapping; action error response body surfaced to the status bar instead of being swallowed. - E2E/VM: live installed-gateway empty-service snapshot works; live From c2fb4b7715337690ec0e4c9146d54a95ab7f0155 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 08:53:36 -0400 Subject: [PATCH 26/35] fix: move tui help hint to session stats --- CHANGELOG.md | 15 ++++++++------- crates/capsem-tui/src/tests.rs | 17 +++++++++++++++-- crates/capsem-tui/src/ui.rs | 13 +++++++++++-- justfile | 2 +- sprints/tui-control/MASTER.md | 17 +++++++++-------- sprints/tui-control/tracker.md | 19 ++++++++++--------- 6 files changed, 54 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6c5487..bfbb6d25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,8 +111,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Alt+l` sessions overlay, and clearer `Alt+i` session info. - Added focused-field highlighting to `capsem-tui` create and fork dialogs so the active input and selected profile are visible. -- Changed the far-left `capsem-tui` status hint to `help: alt+/` so it no - longer conflicts with `Alt+s` suspend. +- Changed the `capsem-tui` status hint to `help: alt+?` and moved it to the + far right after active-session statistics, including the empty-session state. - Added Linux KVM doctor coverage that creates and resolves symlinks under `/tmp`, keeping link-heavy cache/tool probes off the VirtioFS workspace while @@ -163,11 +163,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed `capsem-tui` service latency rendering to keep the status dot glued to the latency field, making the service block read as one unit. - Fixed `capsem-tui` shell controls to use an app-owned Alt namespace: - `Alt+Left/Right`, `Alt+1..9`, `Alt+n/r/s/t/d`, and `Alt+q`, instead of - terminal-dependent Cmd/Ctrl forwarding or prefix fallbacks. -- Fixed `capsem-tui` help and modal handling by accepting both `Alt+?` and - `Alt+/`, rendering overlays through Ratatui modal widgets, and resending the - active terminal geometry whenever the real terminal size changes. + `Alt+Left/Right`, `Alt+1..9`, `Alt+n/f/r/s/c/t/d`, `Alt+?`, `Alt+i`, + `Alt+l`, and `Alt+q`, instead of terminal-dependent Cmd/Ctrl forwarding or + prefix fallbacks. +- Fixed `capsem-tui` help and modal handling by using `Alt+?` for help, + rendering overlays through Ratatui modal widgets, and resending the active + terminal geometry whenever the real terminal size changes. - Fixed `capsem-tui` modal input ownership so `Esc` closes non-confirmation overlays, visible modals consume normal keys, and plain VM input resumes forwarding as soon as the modal closes. diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 857167e8..714fa514 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -28,10 +28,10 @@ fn fixture_models_global_service_state_and_session_indicators() { fn snapshot_contains_light_bar_tabs_and_active_desktop() { let snapshot = render_snapshot(&fixture_state(), 100, 24).expect("render snapshot"); - assert!(snapshot.contains("help: alt+/ 18ms●")); + assert!(snapshot.contains(" 18ms●")); assert!(snapshot.contains("1 profile-v2")); assert!(snapshot.contains("2 linux-os!")); - assert!(snapshot.contains("◷ 47m | # 38.4k | $ 0.21")); + assert!(snapshot.contains("◷ 47m | # 38.4k | $ 0.21 | help: alt+?")); assert!( !snapshot.contains("github.com/google/capsem"), "repo metadata belongs in a popup or future status segment, not the empty terminal surface" @@ -46,6 +46,17 @@ fn snapshot_contains_light_bar_tabs_and_active_desktop() { ); } +#[test] +fn no_session_status_bar_keeps_help_hint_on_the_right() { + let mut state = fixture_state(); + state.active_session_id.clear(); + state.sessions.clear(); + + let snapshot = render_snapshot(&state, 100, 24).expect("render empty snapshot"); + + assert!(snapshot.contains("no session | help: alt+?")); +} + #[test] fn tab_colors_use_selected_yellow_and_unselected_blue_only() { let buffer = render_test_buffer(&fixture_state(), 100, 24).expect("render buffer"); @@ -254,6 +265,8 @@ fn help_lists_save_sessions_status_and_fork_shortcuts() { assert!(snapshot.contains("Key")); assert!(snapshot.contains("Action")); + assert!(snapshot.contains("Alt+?")); + assert!(snapshot.contains("help")); assert!(snapshot.contains("Alt+s")); assert!(snapshot.contains("suspend")); assert!(snapshot.contains("Alt+c")); diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 6558e89c..25b6274f 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -120,7 +120,6 @@ fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { frame.render_widget(Paragraph::new("").style(base), area); let mut left = vec![ - Span::styled("help: alt+/ ", muted_style()), Span::styled(format!("{:>4}ms", service.latency.as_millis()), base), Span::styled( service_dot(service.status), @@ -141,7 +140,7 @@ fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { let right = state .active_session() .map(active_stats_spans) - .unwrap_or_else(|| vec![Span::styled(" no session ", muted_style())]); + .unwrap_or_else(no_session_stats_spans); let left_width = spans_width(&left).min(area.width as usize) as u16; let right_width = spans_width(&right).min(area.width as usize) as u16; @@ -383,6 +382,7 @@ fn help_lines() -> Vec> { vec![ overlay_title("keys"), table_header(&["Key", "Action", "Scope", "Note"]), + help_row("Alt+?", "help", "global", "show this table"), help_row("Alt+Left", "previous", "global", "switch session"), help_row("Alt+Right", "next", "global", "switch session"), help_row("Alt+1..9", "jump", "global", "select by tab number"), @@ -824,6 +824,15 @@ fn active_stats_spans(session: &SessionSummary) -> Vec> { Span::styled(format_tokens(session.stats.tokens), stats_style()), Span::styled(" | $ ", muted_style()), Span::styled(format_cost_amount(session.stats.cost_micros), stats_style()), + Span::styled(" | help: alt+?", muted_style()), + Span::styled(" ", stats_style()), + ] +} + +fn no_session_stats_spans() -> Vec> { + vec![ + Span::styled(" no session", muted_style()), + Span::styled(" | help: alt+?", muted_style()), Span::styled(" ", stats_style()), ] } diff --git a/justfile b/justfile index 653f2841..14c11b75 100644 --- a/justfile +++ b/justfile @@ -259,7 +259,7 @@ dev-frontend: _pnpm-install # App-owned controls: Alt+Left/Right switch sessions; Alt+1..9 jumps; # Alt+n new, Alt+f fork, Alt+r resume, Alt+s suspend, Alt+c checkpoint, # Alt+t stop, Alt+d delete, Alt+q quit; -# Alt+?/Alt+/ help, Alt+i session info, Alt+l sessions. Plain q/Ctrl-C pass to the VM. +# Alt+? help, Alt+i session info, Alt+l sessions. Plain q/Ctrl-C pass to the VM. # Pass extra args after `--`: `just dev-tui -- --snapshot`. dev-tui *ARGS: cargo run -p capsem-tui {{ARGS}} diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index 325084d5..a9070463 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -105,10 +105,10 @@ attention markers. screen state, SGR colors, and text attributes. Client-side adjacent output coalescing and dirty-frame redraws now mirror the existing `capsem shell` speed contract instead of repainting on every loop. -- Tightened interactive control polish: help opens on both `Alt+?` and - terminal-encoded `Alt+/`, overlays render as Ratatui modal blocks, service - latency renders as a glued `####ms●` segment, and active terminal geometry is - resent whenever the real terminal size changes. +- Tightened interactive control polish: help opens on `Alt+?`, overlays render + as Ratatui modal blocks, service latency renders as a glued `####ms●` + segment, and active terminal geometry is resent whenever the real terminal + size changes. - Simplified human-facing tab colors: selected VM is yellow, every other VM tab is blue. Modal overlays now close with `Esc`, own normal keys while visible, and release VM input forwarding immediately after close. @@ -125,8 +125,8 @@ attention markers. `Press Enter to resume` prompt, Enter invokes resume for the active inactive session, and the terminal WebSocket bridge disconnects instead of reconnecting to stopped VM sockets. -- Added a far-left `help: alt+/` hint before service latency/status so help is - discoverable without conflicting with `Alt+s` suspend. +- Added a far-right `help: alt+?` hint after active-session statistics so help + is discoverable without competing with service health or `Alt+s` suspend. - Corrected `Alt+n` from one-key ephemeral provisioning into a profile-aware new-session modal. The flow pre-fills the next unused `tmp-*` name, supports typing/backspace, lets Up/Down choose a live `/profiles` entry, and sends a @@ -163,8 +163,9 @@ attention markers. modal/action path, plus `Alt+l` sessions, `Alt+i` session info, and `Alt+c` checkpoint. Live snapshot against the installed stopped `tui-proof-*` sessions shows the prompt instead of a blank pane. -- UI polish: `cargo test -p capsem-tui` and snapshot output cover the far-left - `help: alt+/` status-bar hint and focused-field highlighting. +- UI polish: `cargo test -p capsem-tui` and snapshot output cover the + right-side `help: alt+?` status-bar hint after session stats and + focused-field highlighting, including the no-active-session status-bar path. - New-session regression: `cargo test -p capsem-tui` covers create-modal rendering, profile selection, name editing, and authenticated named-profile provision request payloads. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 1028e6cc..10105ac9 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -110,8 +110,8 @@ reserved latency field so it reads as one service-health segment. - Navigation is now app-owned: `Alt+Left/Right` switches sessions and `Alt+1..9` jumps by tab number. `Alt+n/f/r/s/c/t/d`, `Alt+q`, - `Alt+?`/`Alt+/`, `Alt+i`, and `Alt+l` cover shell actions, exit, help, - session info, and session list. + `Alt+?`, `Alt+i`, and `Alt+l` cover shell actions, exit, help, session + info, and session list. - Tab colors now use one semantic: selected is yellow, every other VM tab is blue. Bell/attention state keeps its text marker but no longer changes the tab color. @@ -159,8 +159,8 @@ tried to connect. Stopped/suspended/failed tabs now grey out, the pane shows `Press Enter to resume`, Enter invokes resume for the active inactive session, and the terminal bridge disconnects from inactive tabs. -- Discoverability polish: the persistent status segment now starts with - `help: alt+/` before service latency/status. The full command list remains +- Discoverability polish: the persistent status segment now ends with + `help: alt+?` after active-session statistics. The full command list remains in the help overlay. - New-session flow correction: `Alt+n` now opens a `new session` modal instead of immediately provisioning an ephemeral VM. The modal pre-fills the name as @@ -176,12 +176,13 @@ ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui` (35 tests), including +- Unit/contract: `cargo test -p capsem-tui` (36 tests), including stopped-session resume prompt, grey tab, Enter-to-resume coverage, and the - far-left `help: alt+/` status-bar hint, plus the create modal profile/name - flow, selected-field highlighting, named fork modal/action coverage, - `Alt+l` sessions table, `Alt+i` session info, and `Alt+c` checkpoint. -- TUI latency/provider: `cargo test -p capsem-tui` (35 tests), including + right-side `help: alt+?` status-bar hint after session stats, plus the create + modal profile/name flow, selected-field highlighting, named fork modal/action + coverage, `Alt+l` sessions table, `Alt+i` session info, and `Alt+c` + checkpoint. +- TUI latency/provider: `cargo test -p capsem-tui` (36 tests), including token reuse, live profile-list refresh, named fork request payloads, checkpoint-over-suspend payloads, and raw local latency preservation coverage. From 921431197472bfb5a21212faf5b4aaa3f43d10e6 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 08:58:08 -0400 Subject: [PATCH 27/35] fix: open tui new session on empty state --- CHANGELOG.md | 2 ++ crates/capsem-tui/src/app.rs | 6 ++++- crates/capsem-tui/src/main.rs | 21 +++++++++++------ crates/capsem-tui/src/tests.rs | 26 ++++++++++++++++++++ crates/capsem-tui/src/ui.rs | 43 +++++++++++++++++++++++++++------- sprints/tui-control/MASTER.md | 6 ++++- sprints/tui-control/tracker.md | 14 +++++++---- 7 files changed, 97 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfbb6d25..06b6f6c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Alt+l` sessions overlay, and clearer `Alt+i` session info. - Added focused-field highlighting to `capsem-tui` create and fork dialogs so the active input and selected profile are visible. +- Added an empty-state `capsem-tui` startup path that opens the new-session + modal directly and brands it with a compact gradient CAPSEM wordmark. - Changed the `capsem-tui` status hint to `help: alt+?` and moved it to the far right after active-session statistics, including the empty-session state. diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index 5544462c..0bf0667e 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -88,14 +88,18 @@ impl App { .iter() .position(|session| session.id == state.active_session_id) .unwrap_or_default(); - Self { + let mut app = Self { state, active_index, overlay: AppOverlay::None, pending_action: None, create_draft: None, fork_draft: None, + }; + if app.state.sessions.is_empty() { + app.open_create(); } + app } pub fn state(&self) -> &AppState { diff --git a/crates/capsem-tui/src/main.rs b/crates/capsem-tui/src/main.rs index 1cad345f..bfcaa5b2 100644 --- a/crates/capsem-tui/src/main.rs +++ b/crates/capsem-tui/src/main.rs @@ -10,7 +10,7 @@ use capsem_tui::gateway_provider::{ActionOutcome, GatewayProvider}; use capsem_tui::model::{AppState, ServiceStatus, SessionLifecycle}; use capsem_tui::provider::StateProvider; use capsem_tui::terminal::{key_to_terminal_bytes, TerminalBridge, TerminalSurface}; -use capsem_tui::ui::{render_app, render_snapshot, render_svg_snapshot}; +use capsem_tui::ui::{render_app, render_app_snapshot, render_app_svg_snapshot}; use clap::Parser; use crossterm::event::{self, Event, KeyEventKind}; use crossterm::execute; @@ -57,21 +57,28 @@ struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); let state = load_state(&cli)?; - let live_provider = live_provider(&cli); - let terminal_bridge = live_provider - .as_ref() - .map(|provider| TerminalBridge::spawn(provider.base_url().to_string())); if cli.snapshot_svg { - println!("{}", render_svg_snapshot(&state, cli.width, cli.height)?); + println!( + "{}", + render_app_svg_snapshot(&App::new(state), cli.width, cli.height)? + ); return Ok(()); } if cli.snapshot { - println!("{}", render_snapshot(&state, cli.width, cli.height)?); + println!( + "{}", + render_app_snapshot(&App::new(state), cli.width, cli.height)? + ); return Ok(()); } + let live_provider = live_provider(&cli); + let terminal_bridge = live_provider + .as_ref() + .map(|provider| TerminalBridge::spawn(provider.base_url().to_string())); + run_interactive( App::new(state), live_provider, diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 714fa514..d31581c3 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -57,6 +57,32 @@ fn no_session_status_bar_keeps_help_hint_on_the_right() { assert!(snapshot.contains("no session | help: alt+?")); } +#[test] +fn empty_state_opens_new_session_modal_with_gradient_logo() { + let mut state = fixture_state(); + state.active_session_id.clear(); + state.sessions.clear(); + + let app = App::new(state); + + assert_eq!(app.overlay(), AppOverlay::Create); + assert_eq!(app.create_draft().expect("create draft").name, "tmp-1"); + let snapshot = render_app_snapshot(&app, 100, 24).expect("render empty create modal"); + assert!(snapshot.contains("CAPSEM")); + assert!(snapshot.contains("new session")); + + let buffer = render_app_test_buffer(&app, 100, 24).expect("render logo buffer"); + let (logo_x, logo_y) = find_cell(&buffer, "CAPSEM"); + let first = buffer_cell(&buffer, logo_x, logo_y); + let last = buffer_cell(&buffer, logo_x + 5, logo_y); + assert_ne!( + first.fg, last.fg, + "logo letters should use a visible gradient, not one flat color" + ); + assert!(first.modifier.contains(Modifier::BOLD)); + assert!(last.modifier.contains(Modifier::BOLD)); +} + #[test] fn tab_colors_use_selected_yellow_and_unselected_blue_only() { let buffer = render_test_buffer(&fixture_state(), 100, 24).expect("render buffer"); diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 25b6274f..5cbd6e25 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -21,6 +21,14 @@ const ACTIVE: Color = Color::Rgb(137, 180, 250); const ATTENTION: Color = Color::Rgb(249, 226, 175); const BAD: Color = Color::Rgb(243, 139, 168); const SELECTED_BG: Color = Color::Rgb(49, 50, 68); +const LOGO_GRADIENT: [Color; 6] = [ + Color::Rgb(137, 220, 235), + Color::Rgb(116, 199, 236), + Color::Rgb(137, 180, 250), + Color::Rgb(203, 166, 247), + Color::Rgb(245, 194, 231), + Color::Rgb(249, 226, 175), +]; pub fn render(frame: &mut Frame<'_>, state: &AppState) { render_with_terminal(frame, state, None); @@ -83,10 +91,18 @@ pub fn render_svg_snapshot(state: &AppState, width: u16, height: u16) -> Result< } pub fn render_app_snapshot(app: &App, width: u16, height: u16) -> Result { + Ok(buffer_to_string(&render_app_buffer(app, width, height)?)) +} + +pub fn render_app_svg_snapshot(app: &App, width: u16, height: u16) -> Result { + Ok(buffer_to_svg(&render_app_buffer(app, width, height)?)) +} + +fn render_app_buffer(app: &App, width: u16, height: u16) -> Result { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| render_app(frame, app, None))?; - Ok(buffer_to_string(terminal.backend().buffer())) + Ok(terminal.backend().buffer().clone()) } fn render_buffer(state: &AppState, width: u16, height: u16) -> Result { @@ -103,10 +119,7 @@ pub(crate) fn render_test_buffer(state: &AppState, width: u16, height: u16) -> R #[cfg(test)] pub(crate) fn render_app_test_buffer(app: &App, width: u16, height: u16) -> Result { - let backend = TestBackend::new(width, height); - let mut terminal = Terminal::new(backend)?; - terminal.draw(|frame| render_app(frame, app, None))?; - Ok(terminal.backend().buffer().clone()) + render_app_buffer(app, width, height) } fn render_status_bar(frame: &mut Frame<'_>, state: &AppState, area: Rect) { @@ -370,8 +383,8 @@ fn overlay_height(state: &AppState, overlay: AppOverlay) -> u16 { AppOverlay::Stats => 12, AppOverlay::Home => (state.sessions.len() as u16).saturating_add(5).clamp(7, 16), AppOverlay::Create => (state.profiles.len() as u16) - .saturating_add(9) - .clamp(11, 18), + .saturating_add(10) + .clamp(12, 18), AppOverlay::Fork => 8, AppOverlay::Confirm => 6, AppOverlay::None => 0, @@ -412,7 +425,7 @@ fn confirm_lines(action: Option<&ControlAction>) -> Vec> { } fn create_lines(state: &AppState, draft: Option<&CreateDraft>) -> Vec> { - let mut lines = vec![overlay_title("new session")]; + let mut lines = vec![logo_line(), overlay_title("new session")]; let name = draft .map(|draft| draft.name.as_str()) .filter(|name| !name.is_empty()) @@ -552,6 +565,20 @@ fn overlay_title(title: &'static str) -> Line<'static> { )) } +fn logo_line() -> Line<'static> { + let mut spans = vec![Span::styled(" ", status_base_style())]; + for (index, ch) in "CAPSEM".chars().enumerate() { + spans.push(Span::styled( + ch.to_string(), + Style::default() + .fg(LOGO_GRADIENT[index]) + .bg(BAR_BG) + .add_modifier(Modifier::BOLD), + )); + } + Line::from(spans) +} + fn overlay_line(text: &str) -> Line<'static> { Line::from(Span::styled(text.to_string(), status_base_style())) } diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index a9070463..e3dabf8b 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -141,6 +141,9 @@ attention markers. is the primary session-list chord, `Alt+i` opens active-session info, and create/fork modals now visibly highlight the active input and selected profile row. +- Added the empty-state startup path: when no sessions exist, the TUI opens + the new-session modal directly. Text and SVG snapshots now use the same app + renderer, and the modal includes a compact gradient CAPSEM wordmark. ## Testing Gate @@ -165,7 +168,8 @@ attention markers. sessions shows the prompt instead of a blank pane. - UI polish: `cargo test -p capsem-tui` and snapshot output cover the right-side `help: alt+?` status-bar hint after session stats and - focused-field highlighting, including the no-active-session status-bar path. + focused-field highlighting, including the no-active-session status-bar path + and the branded empty-state new-session modal. - New-session regression: `cargo test -p capsem-tui` covers create-modal rendering, profile selection, name editing, and authenticated named-profile provision request payloads. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 10105ac9..a59bc92b 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -40,6 +40,8 @@ - [x] Rework help, session list, and session info into structured readable tables. - [x] Highlight active modal fields and selected profile rows. +- [x] Open the new-session modal automatically when no sessions exist. +- [x] Add gradient CAPSEM wordmark to the new-session modal. - [x] Run live installed-gateway empty-service snapshot. - [x] Run live two-session terminal proof. - [x] Commit functional milestone. @@ -173,16 +175,20 @@ - Usability correction: full help is now a structured table, `Alt+l` opens the session list table, `Alt+i` opens session info, and create/fork modals highlight the active input plus the selected profile row. +- Empty-state correction: startup with no sessions now opens the `new session` + modal directly, and text/SVG snapshots use the same app renderer so the empty + service proof matches the interactive path. The modal carries a compact + gradient CAPSEM wordmark. ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui` (36 tests), including +- Unit/contract: `cargo test -p capsem-tui` (37 tests), including stopped-session resume prompt, grey tab, Enter-to-resume coverage, and the right-side `help: alt+?` status-bar hint after session stats, plus the create modal profile/name flow, selected-field highlighting, named fork modal/action - coverage, `Alt+l` sessions table, `Alt+i` session info, and `Alt+c` - checkpoint. -- TUI latency/provider: `cargo test -p capsem-tui` (36 tests), including + coverage, empty-state auto-create, gradient logo rendering, `Alt+l` sessions + table, `Alt+i` session info, and `Alt+c` checkpoint. +- TUI latency/provider: `cargo test -p capsem-tui` (37 tests), including token reuse, live profile-list refresh, named fork request payloads, checkpoint-over-suspend payloads, and raw local latency preservation coverage. From 53862ec2ad725d61cfb9bcbb0559efa8ae42d85c Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 09:09:05 -0400 Subject: [PATCH 28/35] fix: block tui create without profiles --- CHANGELOG.md | 4 ++ crates/capsem-tui/src/app.rs | 8 ++- crates/capsem-tui/src/gateway_provider.rs | 8 --- crates/capsem-tui/src/tests.rs | 75 +++++++++++++++++++++++ crates/capsem-tui/src/ui.rs | 11 ++-- sprints/tui-control/MASTER.md | 16 ++++- sprints/tui-control/tracker.md | 31 +++++++--- tests/capsem-gateway/test_gw_e2e.py | 53 ++++++++++++++++ 8 files changed, 183 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06b6f6c7..27de8e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 results. ### Fixed +- Fixed `capsem-tui` empty-session creation so the TUI no longer invents a + `default` profile when `/profiles` is unavailable; the new-session modal now + blocks Enter until a real profile list is loaded and has unit plus gateway + E2E coverage for the profile-backed create contract. - Fixed `capsem-tui` stopped-session rendering so stopped/suspended/failed tabs are greyed, the main pane shows a `Press Enter to resume` affordance instead of going blank, and the terminal bridge disconnects instead of trying diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index 0bf0667e..d8b7e6f5 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -367,7 +367,10 @@ impl App { if name.is_empty() { return AppAction::Consumed; } - let profile_id = selected_profile_id(&self.state, draft.selected_profile); + let Some(profile_id) = selected_profile_id(&self.state, draft.selected_profile) + else { + return AppAction::Consumed; + }; self.create_draft = None; self.overlay = AppOverlay::None; AppAction::Invoke(ControlAction::CreateSession { name, profile_id }) @@ -485,13 +488,12 @@ fn default_profile_index(state: &AppState) -> usize { .unwrap_or_default() } -fn selected_profile_id(state: &AppState, index: usize) -> String { +fn selected_profile_id(state: &AppState, index: usize) -> Option { state .profiles .get(index) .or_else(|| state.profiles.first()) .map(|profile| profile.id.clone()) - .unwrap_or_else(|| "default".to_string()) } fn next_tmp_name(state: &AppState) -> String { diff --git a/crates/capsem-tui/src/gateway_provider.rs b/crates/capsem-tui/src/gateway_provider.rs index 994392b9..a622f5bf 100644 --- a/crates/capsem-tui/src/gateway_provider.rs +++ b/crates/capsem-tui/src/gateway_provider.rs @@ -226,14 +226,6 @@ fn profiles_from_sessions(state: &AppState) -> Vec { is_default: profiles.is_empty(), }); } - if profiles.is_empty() { - profiles.push(ProfileOption { - id: "default".to_string(), - name: "default".to_string(), - description: None, - is_default: true, - }); - } profiles } diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index d31581c3..3cf8f995 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -83,6 +83,29 @@ fn empty_state_opens_new_session_modal_with_gradient_logo() { assert!(last.modifier.contains(Modifier::BOLD)); } +#[test] +fn empty_create_modal_blocks_enter_when_profiles_are_unavailable() { + let mut state = fixture_state(); + state.active_session_id.clear(); + state.sessions.clear(); + state.profiles.clear(); + let mut app = App::new(state); + + let snapshot = render_app_snapshot(&app, 100, 24).expect("render empty create modal"); + assert!(snapshot.contains("profiles unavailable")); + assert!( + !snapshot.contains("▶ default"), + "the TUI must not invent a default profile when profile discovery failed" + ); + + assert_eq!( + app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE)), + AppAction::Consumed, + "create should be disabled until a real profile list is available" + ); + assert_eq!(app.overlay(), AppOverlay::Create); +} + #[test] fn tab_colors_use_selected_yellow_and_unselected_blue_only() { let buffer = render_test_buffer(&fixture_state(), 100, 24).expect("render buffer"); @@ -650,6 +673,48 @@ async fn gateway_provider_loads_status_over_http_gateway() { server.await.expect("server task"); } +#[tokio::test] +async fn gateway_provider_does_not_invent_default_profile_when_profiles_fail() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test gateway"); + let addr = listener.local_addr().expect("local addr"); + let server = tokio::spawn(async move { + for _ in 0..3 { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let request = read_http_request(&mut stream).await; + if request.contains("GET /token ") { + write_json_response(&mut stream, r#"{"token":"test-token"}"#).await; + } else if request.contains("GET /status ") { + write_json_response(&mut stream, gateway_empty_status_body()).await; + } else { + assert!( + request.contains("GET /profiles "), + "unexpected request: {request:?}" + ); + write_response( + &mut stream, + "502 Bad Gateway", + r#"{"error":"service profile discovery unavailable"}"#, + ) + .await; + } + } + }); + + let state = GatewayProvider::new(format!("http://{addr}")) + .load_async() + .await + .expect("load state over gateway"); + + assert!(state.sessions.is_empty()); + assert!( + state.profiles.is_empty(), + "profile discovery failure with no sessions must not synthesize default" + ); + server.await.expect("server task"); +} + #[tokio::test] async fn gateway_provider_reuses_token_across_status_refreshes() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") @@ -1032,6 +1097,16 @@ fn gateway_status_body() -> &'static str { }"# } +fn gateway_empty_status_body() -> &'static str { + r#"{ + "service": "running", + "gateway_version": "test", + "vm_count": 0, + "resource_summary": null, + "vms": [] + }"# +} + fn gateway_profiles_body() -> &'static str { r#"{ "mode": "settings_profiles_v2", diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 5cbd6e25..01ad39ea 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -434,15 +434,18 @@ fn create_lines(state: &AppState, draft: Option<&CreateDraft>) -> Vec Date: Sat, 30 May 2026 09:29:21 -0400 Subject: [PATCH 29/35] fix: prompt tui service start when offline --- CHANGELOG.md | 4 ++ crates/capsem-tui/src/app.rs | 34 ++++++++++-- crates/capsem-tui/src/fixture.rs | 15 ++++++ crates/capsem-tui/src/gateway_provider.rs | 64 +++++++++++++++++++++-- crates/capsem-tui/src/main.rs | 12 +---- crates/capsem-tui/src/tests.rs | 56 +++++++++++++++++++- crates/capsem-tui/src/ui.rs | 40 ++++++++++++++ sprints/tui-control/MASTER.md | 8 +++ sprints/tui-control/tracker.md | 23 +++++--- 9 files changed, 231 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27de8e8d..e1b0cdc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 results. ### Fixed +- Fixed `capsem-tui` service-offline startup so the TUI shows an offline + service surface and asks to start Capsem before opening the new-session flow; + confirming the prompt runs the local `capsem start` command and refreshes + with a fresh gateway token. - Fixed `capsem-tui` empty-session creation so the TUI no longer invents a `default` profile when `/profiles` is unavailable; the new-session modal now blocks Enter until a real profile list is loaded and has unit plus gateway diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index d8b7e6f5..339b6b2d 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -1,6 +1,6 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use crate::model::{AppState, SessionLifecycle}; +use crate::model::{AppState, ServiceStatus, SessionLifecycle}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum AppAction { @@ -24,6 +24,7 @@ pub enum AppOverlay { #[derive(Clone, Debug, Eq, PartialEq)] pub enum ControlAction { + StartService, CreateSession { name: String, profile_id: String }, Fork { id: String, name: String }, Resume { name: String }, @@ -36,6 +37,7 @@ pub enum ControlAction { impl ControlAction { pub const fn label(&self) -> &'static str { match self { + Self::StartService => "start service", Self::CreateSession { .. } => "create", Self::Fork { .. } => "fork", Self::Resume { .. } => "resume", @@ -48,6 +50,7 @@ impl ControlAction { pub fn target(&self) -> &str { match self { + Self::StartService => "Capsem service", Self::CreateSession { name, .. } => name, Self::Fork { name, .. } => name, Self::Resume { name } @@ -96,9 +99,7 @@ impl App { create_draft: None, fork_draft: None, }; - if app.state.sessions.is_empty() { - app.open_create(); - } + app.sync_empty_state_prompt(); app } @@ -139,6 +140,7 @@ impl App { .unwrap_or_default(); self.state = state; self.sync_active_session(); + self.sync_empty_state_prompt(); } pub fn set_control_message(&mut self, message: impl Into) { @@ -236,6 +238,23 @@ impl App { self.state.active_session_id.clone_from(&session.id); } + fn sync_empty_state_prompt(&mut self) { + if service_needs_start(self.state.service.status) { + self.create_draft = None; + self.fork_draft = None; + self.pending_action = Some(ControlAction::StartService); + self.overlay = AppOverlay::Confirm; + return; + } + if matches!(self.pending_action, Some(ControlAction::StartService)) { + self.pending_action = None; + self.overlay = AppOverlay::None; + } + if self.state.sessions.is_empty() && self.overlay == AppOverlay::None { + self.open_create(); + } + } + fn handle_overlay_key(&mut self, key: KeyEvent) -> bool { if !is_alt_key(key.modifiers) { return false; @@ -480,6 +499,13 @@ fn is_alt_key(modifiers: KeyModifiers) -> bool { modifiers.contains(KeyModifiers::ALT) } +fn service_needs_start(status: ServiceStatus) -> bool { + matches!( + status, + ServiceStatus::Offline | ServiceStatus::Degraded | ServiceStatus::Failed + ) +} + fn default_profile_index(state: &AppState) -> usize { state .profiles diff --git a/crates/capsem-tui/src/fixture.rs b/crates/capsem-tui/src/fixture.rs index 4ac9ff62..0f678087 100644 --- a/crates/capsem-tui/src/fixture.rs +++ b/crates/capsem-tui/src/fixture.rs @@ -79,3 +79,18 @@ pub fn fixture_state() -> AppState { ], } } + +pub fn offline_state() -> AppState { + AppState { + service: ServiceState { + status: ServiceStatus::Offline, + latency: Duration::ZERO, + last_event_age: Duration::ZERO, + reconnect_attempt: Some(1), + control_message: None, + }, + active_session_id: String::new(), + profiles: Vec::new(), + sessions: Vec::new(), + } +} diff --git a/crates/capsem-tui/src/gateway_provider.rs b/crates/capsem-tui/src/gateway_provider.rs index a622f5bf..233c9236 100644 --- a/crates/capsem-tui/src/gateway_provider.rs +++ b/crates/capsem-tui/src/gateway_provider.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -44,6 +44,15 @@ impl GatewayProvider { Ok(token) } + fn clear_auth_token(&self) -> Result<()> { + let mut cached = self + .token + .lock() + .map_err(|_| anyhow::anyhow!("capsem gateway token cache poisoned"))?; + *cached = None; + Ok(()) + } + async fn token(&self) -> Result { if let Some(token) = self.auth_token()? { return Ok(token); @@ -75,9 +84,16 @@ impl GatewayProvider { } pub async fn load_async(&self) -> Result { - let token = self.token().await?; + let mut token = self.token().await?; let started = Instant::now(); - let status = fetch_status(&self.client, &self.base_url, &token).await?; + let status = match fetch_status(&self.client, &self.base_url, &token).await { + Ok(status) => status, + Err(first_error) => { + self.clear_auth_token()?; + token = self.token().await.context(first_error)?; + fetch_status(&self.client, &self.base_url, &token).await? + } + }; let mut state = status_response_to_state(status, started.elapsed()); state.profiles = self.profile_options(&token, &state).await; Ok(state) @@ -92,6 +108,9 @@ impl GatewayProvider { } pub async fn invoke_async(&self, action: &ControlAction) -> Result { + if matches!(action, ControlAction::StartService) { + return start_service().await; + } let token = self.token().await?; invoke_action(&self.client, &self.base_url, &token, action).await } @@ -322,6 +341,7 @@ async fn invoke_action( action: &ControlAction, ) -> Result { match action { + ControlAction::StartService => start_service().await, ControlAction::CreateSession { name, profile_id } => { let response = client .post(join_url(base_url, &["provision"])?) @@ -399,6 +419,44 @@ async fn invoke_action( } } +async fn start_service() -> Result { + start_service_with_binary(&capsem_binary()).await +} + +pub(crate) async fn start_service_with_binary(binary: &Path) -> Result { + let output = tokio::process::Command::new(binary) + .arg("start") + .output() + .await + .with_context(|| format!("run {} start", binary.display()))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if stderr.is_empty() { stdout } else { stderr }; + anyhow::bail!("capsem start failed: {detail}"); + } + Ok(ActionOutcome { + message: "service start requested".to_string(), + }) +} + +fn capsem_binary() -> PathBuf { + if let Ok(path) = std::env::var("CAPSEM_TUI_CAPSEM_BINARY") { + return PathBuf::from(path); + } + let installed = home_dir().join(".capsem/bin/capsem"); + if installed.exists() { + return installed; + } + PathBuf::from("capsem") +} + +fn home_dir() -> PathBuf { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")) +} + async fn post_empty( client: &reqwest::Client, base_url: &str, diff --git a/crates/capsem-tui/src/main.rs b/crates/capsem-tui/src/main.rs index bfcaa5b2..8103c558 100644 --- a/crates/capsem-tui/src/main.rs +++ b/crates/capsem-tui/src/main.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use capsem_tui::app::{App, AppAction, ControlAction}; -use capsem_tui::fixture::FixtureProvider; +use capsem_tui::fixture::{offline_state, FixtureProvider}; use capsem_tui::gateway_provider::{ActionOutcome, GatewayProvider}; use capsem_tui::model::{AppState, ServiceStatus, SessionLifecycle}; use capsem_tui::provider::StateProvider; @@ -98,15 +98,7 @@ fn load_state(cli: &Cli) -> Result { .unwrap_or_else(GatewayProvider::default_base_url); match GatewayProvider::new(base_url.clone()).load() { Ok(state) => Ok(state), - Err(_) if cli.gateway_url.is_none() => { - let mut state = FixtureProvider - .load() - .context("load capsem-tui fallback fixture")?; - state.service.status = ServiceStatus::Offline; - state.service.latency = Duration::ZERO; - state.service.reconnect_attempt = Some(1); - Ok(state) - } + Err(_) if cli.gateway_url.is_none() => Ok(offline_state()), Err(error) => { Err(error).with_context(|| format!("load capsem gateway state from {base_url}")) } diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 3cf8f995..e8e5bbc8 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -3,8 +3,10 @@ use ratatui::style::{Color, Modifier}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use crate::app::{App, AppAction, AppOverlay, ControlAction}; -use crate::fixture::fixture_state; -use crate::gateway_provider::{state_from_status_json_for_test, GatewayProvider}; +use crate::fixture::{fixture_state, offline_state}; +use crate::gateway_provider::{ + start_service_with_binary, state_from_status_json_for_test, GatewayProvider, +}; use crate::model::{Attention, ServiceStatus, SessionLifecycle}; use crate::ui::{render_app_snapshot, render_app_test_buffer, render_snapshot, render_test_buffer}; @@ -57,6 +59,42 @@ fn no_session_status_bar_keeps_help_hint_on_the_right() { assert!(snapshot.contains("no session | help: alt+?")); } +#[test] +fn offline_empty_state_asks_to_start_service_instead_of_create() { + let mut app = App::new(offline_state()); + + assert_eq!(app.overlay(), AppOverlay::Confirm); + assert_eq!(app.pending_action(), Some(&ControlAction::StartService)); + assert_eq!(app.create_draft(), None); + + let snapshot = render_app_snapshot(&app, 100, 24).expect("render offline start prompt"); + assert!(snapshot.contains("service offline")); + assert!(snapshot.contains("Press Enter to start Capsem service")); + assert!(snapshot.contains("start service")); + assert!( + !snapshot.contains("new session"), + "offline service should ask to start before showing the create flow" + ); + + assert_eq!( + app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE)), + AppAction::Invoke(ControlAction::StartService) + ); +} + +#[test] +fn degraded_empty_state_asks_to_start_service_instead_of_create() { + let mut state = offline_state(); + state.service.status = ServiceStatus::Degraded; + let app = App::new(state); + + assert_eq!(app.overlay(), AppOverlay::Confirm); + assert_eq!(app.pending_action(), Some(&ControlAction::StartService)); + let snapshot = render_app_snapshot(&app, 100, 24).expect("render unavailable start prompt"); + assert!(snapshot.contains("service unavailable")); + assert!(snapshot.contains("start service")); +} + #[test] fn empty_state_opens_new_session_modal_with_gradient_logo() { let mut state = fixture_state(); @@ -83,6 +121,20 @@ fn empty_state_opens_new_session_modal_with_gradient_logo() { assert!(last.modifier.contains(Modifier::BOLD)); } +#[tokio::test] +async fn start_service_action_uses_local_capsem_binary_without_gateway_token() { + let binary = if std::path::Path::new("/bin/true").exists() { + std::path::Path::new("/bin/true") + } else { + std::path::Path::new("/usr/bin/true") + }; + let outcome = start_service_with_binary(binary) + .await + .expect("start service command"); + + assert_eq!(outcome.message, "service start requested"); +} + #[test] fn empty_create_modal_blocks_enter_when_profiles_are_unavailable() { let mut state = fixture_state(); diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index 01ad39ea..d61d5292 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -192,6 +192,10 @@ fn render_terminal_surface( state: &AppState, terminal: Option<&TerminalSurface>, ) { + if service_needs_start(state.service.status) { + render_service_offline_surface(frame, area, state.service.status); + return; + } let Some(session) = state.active_session() else { frame.render_widget( Paragraph::new(Line::from(Span::styled(" no sessions", muted_style()))) @@ -256,6 +260,20 @@ fn render_inactive_session_surface(frame: &mut Frame<'_>, area: Rect, session: & frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); } +fn render_service_offline_surface(frame: &mut Frame<'_>, area: Rect, status: ServiceStatus) { + let lines = vec![ + Line::from(Span::styled( + service_unavailable_title(status), + bad_style().add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "Press Enter to start Capsem service", + status_base_style().add_modifier(Modifier::BOLD), + )), + ]; + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); +} + fn terminal_line_to_ratatui(line: TerminalLine) -> Line<'static> { let spans = line .spans() @@ -309,6 +327,24 @@ fn inactive_session_label(lifecycle: SessionLifecycle) -> &'static str { } } +fn service_needs_start(status: ServiceStatus) -> bool { + matches!( + status, + ServiceStatus::Offline | ServiceStatus::Degraded | ServiceStatus::Failed + ) +} + +fn service_unavailable_title(status: ServiceStatus) -> &'static str { + match status { + ServiceStatus::Offline => "service offline", + ServiceStatus::Degraded => "service unavailable", + ServiceStatus::Failed => "service failed", + ServiceStatus::Online | ServiceStatus::Reconnecting | ServiceStatus::Stale => { + "service unavailable" + } + } +} + fn terminal_color_to_ratatui(color: TerminalColor) -> Option { match color { TerminalColor::Default => None, @@ -773,6 +809,10 @@ fn muted_style() -> Style { Style::default().fg(MUTED).bg(BAR_BG) } +fn bad_style() -> Style { + Style::default().fg(BAD).bg(BAR_BG) +} + fn focus_style() -> Style { Style::default() .fg(ATTENTION) diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index a73542a4..bfd614fe 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -148,6 +148,10 @@ attention markers. discovery failures no longer synthesize `default`, the modal shows `profiles unavailable`, and Enter is disabled until a real profile list is loaded. +- Fixed the service-offline startup path: the TUI now shows a service-offline + surface, opens a `start service` confirmation prompt, runs local + `capsem start` on confirmation, and refreshes with a fresh gateway token + after service/gateway restart. - Added gateway E2E coverage for the TUI create contract: the test runs real `capsem-tui --snapshot` against a real gateway/service, verifies the modal uses the real default profile, then provisions and boot-checks a VM through @@ -182,6 +186,10 @@ attention markers. rendering, profile selection, name editing, and authenticated named-profile provision request payloads, including the no-profile failure path where Enter is blocked instead of creating with a fake `default` profile. +- Service-offline regression: `cargo test -p capsem-tui` covers offline and + gateway-unavailable startup prompting `StartService`, plus local command + execution without fetching a gateway token. A functional snapshot using + `CAPSEM_GATEWAY_URL=http://127.0.0.1:9` proves the visible offline prompt. - Gateway E2E regression: `tests/capsem-gateway/test_gw_e2e.py::TestGatewayE2E::test_tui_empty_create_uses_real_gateway_profiles` is now in the E2E suite. The local run skipped because this checkout is diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index a9c0e977..11895b75 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -189,20 +189,29 @@ to run real `capsem-tui --snapshot` against a real gateway/service, verify the modal uses the real default profile, and provision/boot a VM over the same gateway contract. +- Service-offline correction: when the gateway is unreachable, unavailable, or + failed, `capsem-tui` now renders `service offline` / `service unavailable` + and opens a confirmation prompt for `start service` instead of jumping into + the new-session modal. Confirming runs the local `capsem start` command from + `~/.capsem/bin/capsem` when present, with a `capsem` PATH fallback. +- Token-refresh correction: after a service/gateway restart, the provider now + clears a stale cached gateway token and retries one load with a fresh + `/token`, so a successful start can converge back to live service state. ## Coverage Ledger -- Unit/contract: `cargo test -p capsem-tui` (39 tests), including +- Unit/contract: `cargo test -p capsem-tui` (42 tests), including stopped-session resume prompt, grey tab, Enter-to-resume coverage, and the right-side `help: alt+?` status-bar hint after session stats, plus the create modal profile/name flow, selected-field highlighting, named fork modal/action - coverage, empty-state auto-create, gradient logo rendering, no-fake-default - profile failure handling, `Alt+l` sessions table, `Alt+i` session info, and - `Alt+c` checkpoint. -- TUI latency/provider: `cargo test -p capsem-tui` (39 tests), including + coverage, empty-state auto-create, service-offline start prompt, gradient + logo rendering, no-fake-default profile failure handling, `Alt+l` sessions + table, `Alt+i` session info, and `Alt+c` checkpoint. +- TUI latency/provider: `cargo test -p capsem-tui` (42 tests), including token reuse, live profile-list refresh, named fork request payloads, checkpoint-over-suspend payloads, raw local latency preservation coverage, - and profile discovery failure behavior for empty services. + profile discovery failure behavior for empty services, and local + `capsem start` invocation without requiring a gateway token. - Process IPC: `cargo test -p capsem-process` (120 tests), including `connection_teardown_aborts_writer_and_lifecycle_tasks`. - Service/core/logger hot paths: `cargo test -p capsem-service`, @@ -213,6 +222,8 @@ - Formatting: `cargo fmt -p capsem-tui -- --check`. - Process formatting: `cargo fmt -p capsem-process -- --check`. - Functional: `cargo run -p capsem-tui -- --snapshot --width 100 --height 24`; + `CAPSEM_GATEWAY_URL=http://127.0.0.1:9 cargo run -p capsem-tui -- + --snapshot --width 100 --height 24`; `cargo run -p capsem-tui -- --fixture --snapshot --width 120 --height 30`; `cargo run -p capsem-tui -- --fixture --snapshot-svg --width 120 --height 30`; `cargo run -p capsem-tui -- --snapshot --width 120 --height 30` against From 4d133bb7d96c196a5e204c8d4cc93a19b48d317d Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 10:06:28 -0400 Subject: [PATCH 30/35] bench: rerun mac benchmark after linux merge --- CHANGELOG.md | 4 + .../data_1.2.1779673506_arm64.json | 198 --- .../data_1.2.1780103109_arm64.json | 1552 +++++++++++++++++ .../data_1.2.1780103109_arm64.json | 1008 ++++++----- benchmarks/fork/data_1.2.1779673506.json | 47 - .../fork/data_1.2.1780103109_arm64.json | 81 + .../data_1.2.1780103109_arm64.json | 164 ++ benchmarks/lifecycle/data_1.2.1779673506.json | 57 - .../lifecycle/data_1.2.1780103109_arm64.json | 90 + .../parallel/data_1.2.1780103109_arm64.json | 67 + ...9673506_arm64_dns_request_enforcement.json | 74 - ...673506_arm64_http_request_enforcement.json | 345 ---- ...9673506_arm64_mcp_request_enforcement.json | 75 - ....1779673506_arm64_process_enforcement.json | 65 - ...a_1.2.1780103109_arm64_cel_microbench.json | 783 +++++++++ ...0103109_arm64_dns_request_enforcement.json | 104 ++ ...103109_arm64_http_request_enforcement.json | 374 ++++ ...0103109_arm64_mcp_request_enforcement.json | 106 ++ ....1780103109_arm64_process_enforcement.json | 93 + ...03109_arm64_security_packs_microbench.json | 170 ++ sprints/mac-benchmark-results/tracker.md | 23 +- 21 files changed, 4103 insertions(+), 1377 deletions(-) delete mode 100644 benchmarks/capsem-bench/data_1.2.1779673506_arm64.json create mode 100644 benchmarks/capsem-bench/data_1.2.1780103109_arm64.json delete mode 100644 benchmarks/fork/data_1.2.1779673506.json create mode 100644 benchmarks/fork/data_1.2.1780103109_arm64.json create mode 100644 benchmarks/host-native/data_1.2.1780103109_arm64.json delete mode 100644 benchmarks/lifecycle/data_1.2.1779673506.json create mode 100644 benchmarks/lifecycle/data_1.2.1780103109_arm64.json create mode 100644 benchmarks/parallel/data_1.2.1780103109_arm64.json delete mode 100644 benchmarks/security-engine/data_1.2.1779673506_arm64_dns_request_enforcement.json delete mode 100644 benchmarks/security-engine/data_1.2.1779673506_arm64_http_request_enforcement.json delete mode 100644 benchmarks/security-engine/data_1.2.1779673506_arm64_mcp_request_enforcement.json delete mode 100644 benchmarks/security-engine/data_1.2.1779673506_arm64_process_enforcement.json create mode 100644 benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json create mode 100644 benchmarks/security-engine/data_1.2.1780103109_arm64_dns_request_enforcement.json create mode 100644 benchmarks/security-engine/data_1.2.1780103109_arm64_http_request_enforcement.json create mode 100644 benchmarks/security-engine/data_1.2.1780103109_arm64_mcp_request_enforcement.json create mode 100644 benchmarks/security-engine/data_1.2.1780103109_arm64_process_enforcement.json create mode 100644 benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e19f7568..9a28fb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 macOS comparison reruns as one measured performance stack. - Recorded macOS arm64 benchmark data for `1.2.1779673506`, including in-VM, lifecycle, fork, and security-engine benchmark results. +- Recorded fresh macOS arm64 canonical `just benchmark` data for + `1.2.1780103109` after merging the Linux support branch, including in-VM, + endpoint-latency, host-native, lifecycle, fork, parallel, Criterion, and + VM-originated security-engine benchmark artifacts. - Added `just benchmark-compare` and `scripts/compare_benchmark_artifacts.py` to turn committed Linux/macOS benchmark artifacts into ratio and percentage comparisons while making missing lanes explicit. diff --git a/benchmarks/capsem-bench/data_1.2.1779673506_arm64.json b/benchmarks/capsem-bench/data_1.2.1779673506_arm64.json deleted file mode 100644 index 4c99abc5..00000000 --- a/benchmarks/capsem-bench/data_1.2.1779673506_arm64.json +++ /dev/null @@ -1,198 +0,0 @@ -{ - "version": "0.3.0", - "timestamp": 1780076537.2817547, - "hostname": "bright-ridge-tmp", - "disk": { - "directory": "/root", - "size_mb": 256, - "seq_write": { - "size_bytes": 268435456, - "block_size": 1048576, - "duration_ms": 153.1, - "throughput_mbps": 1672.5 - }, - "seq_read": { - "size_bytes": 268435456, - "block_size": 1048576, - "duration_ms": 69.4, - "throughput_mbps": 3689.2 - }, - "rand_write_4k": { - "count": 10000, - "block_size": 4096, - "duration_ms": 1375.1, - "iops": 7272.2, - "throughput_mbps": 28.4 - }, - "rand_read_4k": { - "count": 10000, - "block_size": 4096, - "duration_ms": 170.9, - "iops": 58509.0, - "throughput_mbps": 228.6 - } - }, - "rootfs": { - "scan_dirs": [ - "/usr/bin", - "/usr/lib", - "/opt/ai-clis" - ], - "largest_file": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", - "largest_file_size": 238401160, - "seq_read": { - "file": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", - "size_bytes": 238401160, - "block_size": 1048576, - "duration_ms": 284.8, - "throughput_mbps": 798.4 - }, - "files_found": 3326, - "rand_read_4k": { - "count": 5000, - "files_sampled": 2576, - "block_size": 4096, - "duration_ms": 654.2, - "iops": 7643.4, - "throughput_mbps": 29.9 - } - }, - "startup": { - "runs_per_command": 3, - "commands": { - "python3": { - "command": [ - "python3", - "--version" - ], - "timings_ms": [ - 7.9, - 5.8, - 9.4 - ], - "min_ms": 5.8, - "mean_ms": 7.7, - "max_ms": 9.4 - }, - "node": { - "command": [ - "node", - "--version" - ], - "timings_ms": [ - 79.4, - 130.5, - 126.5 - ], - "min_ms": 79.4, - "mean_ms": 112.1, - "max_ms": 130.5 - }, - "claude": { - "command": [ - "claude", - "--version" - ], - "timings_ms": [ - 334.6, - 336.0, - 340.1 - ], - "min_ms": 334.6, - "mean_ms": 336.9, - "max_ms": 340.1 - }, - "gemini": { - "command": [ - "gemini", - "--version" - ], - "timings_ms": [ - 760.1, - 757.4, - 757.6 - ], - "min_ms": 757.4, - "mean_ms": 758.4, - "max_ms": 760.1 - }, - "codex": { - "command": [ - "codex", - "--version" - ], - "timings_ms": [ - 285.1, - 236.7, - 228.1 - ], - "min_ms": 228.1, - "mean_ms": 250.0, - "max_ms": 285.1 - } - } - }, - "http": { - "url": "https://www.google.com/", - "total_requests": 50, - "concurrency": 5, - "successful": 50, - "failed": 0, - "total_duration_ms": 930.6, - "requests_per_sec": 53.7, - "transfer_bytes": 4012481, - "latency_ms": { - "min": 59.5, - "max": 248.9, - "mean": 88.9, - "p50": 68.4, - "p95": 227.8, - "p99": 240.1 - } - }, - "throughput": { - "url": "https://cdn.elie.net/static/files/i-am-a-legend/i-am-a-legend-slides.pdf", - "http_code": 200, - "size_bytes": 9984968, - "duration_s": 0.498, - "throughput_mbps": 19.12 - }, - "snapshot": { - "10_files": { - "create_ms": 633.1, - "create_ok": true, - "list_ms": 266.6, - "list_ok": true, - "changes_ms": 284.1, - "changes_ok": true, - "revert_ms": 283.1, - "revert_ok": true, - "delete_ms": 274.5, - "delete_ok": true - }, - "100_files": { - "create_ms": 256.4, - "create_ok": true, - "list_ms": 243.4, - "list_ok": true, - "changes_ms": 248.1, - "changes_ok": true, - "revert_ms": 253.9, - "revert_ok": true, - "delete_ms": 249.1, - "delete_ok": true - }, - "500_files": { - "create_ms": 252.1, - "create_ok": true, - "list_ms": 249.0, - "list_ok": true, - "changes_ms": 262.8, - "changes_ok": true, - "revert_ms": 251.8, - "revert_ok": true, - "delete_ms": 258.5, - "delete_ok": true - } - } -} \ No newline at end of file diff --git a/benchmarks/capsem-bench/data_1.2.1780103109_arm64.json b/benchmarks/capsem-bench/data_1.2.1780103109_arm64.json new file mode 100644 index 00000000..6c8229b1 --- /dev/null +++ b/benchmarks/capsem-bench/data_1.2.1780103109_arm64.json @@ -0,0 +1,1552 @@ +{ + "version": "0.3.0", + "timestamp": 1780149812.1400814, + "hostname": "bench-bb62f401", + "disk": { + "directory": "/root", + "size_mb": 256, + "seq_write": { + "size_bytes": 268435456, + "block_size": 1048576, + "duration_ms": 148.9, + "throughput_mbps": 1719.0 + }, + "seq_read": { + "size_bytes": 268435456, + "block_size": 1048576, + "duration_ms": 63.3, + "throughput_mbps": 4043.0 + }, + "rand_write_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 1019.0, + "iops": 9813.4, + "throughput_mbps": 38.3 + }, + "rand_read_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 111.3, + "iops": 89808.6, + "throughput_mbps": 350.8 + } + }, + "rootfs": { + "scan_dirs": [ + "/usr/bin", + "/usr/lib", + "/opt/ai-clis" + ], + "largest_file": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "largest_file_size": 238401160, + "seq_read": { + "file": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "size_bytes": 238401160, + "block_size": 1048576, + "duration_ms": 240.5, + "throughput_mbps": 945.3 + }, + "files_found": 5557, + "rand_read_4k": { + "count": 5000, + "files_sampled": 2580, + "block_size": 4096, + "duration_ms": 572.5, + "iops": 8733.5, + "throughput_mbps": 34.1 + }, + "large_binary_seq_read": { + "count": 3, + "files": [ + { + "path": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "size_bytes": 238401160, + "cold": { + "file": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "size_bytes": 238401160, + "block_size": 1048576, + "duration_ms": 221.2, + "throughput_mbps": 1027.9 + }, + "warm": { + "file": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "size_bytes": 238401160, + "block_size": 1048576, + "duration_ms": 8.4, + "throughput_mbps": 26972.3 + } + }, + { + "path": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-linux-arm64/claude", + "size_bytes": 238401160, + "cold": { + "file": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-linux-arm64/claude", + "size_bytes": 238401160, + "block_size": 1048576, + "duration_ms": 221.2, + "throughput_mbps": 1027.7 + }, + "warm": { + "file": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-linux-arm64/claude", + "size_bytes": 238401160, + "block_size": 1048576, + "duration_ms": 8.8, + "throughput_mbps": 25827.0 + } + }, + { + "path": "/opt/ai-clis/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-arm64/vendor/aarch64-unknown-linux-musl/bin/codex", + "size_bytes": 187965064, + "cold": { + "file": "/opt/ai-clis/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-arm64/vendor/aarch64-unknown-linux-musl/bin/codex", + "size_bytes": 187965064, + "block_size": 1048576, + "duration_ms": 206.3, + "throughput_mbps": 868.8 + }, + "warm": { + "file": "/opt/ai-clis/lib/node_modules/@openai/codex/node_modules/@openai/codex-linux-arm64/vendor/aarch64-unknown-linux-musl/bin/codex", + "size_bytes": 187965064, + "block_size": 1048576, + "duration_ms": 7.9, + "throughput_mbps": 22630.1 + } + } + ], + "bytes_read": 664767384, + "cold_duration_ms": 648.7, + "warm_duration_ms": 25.1, + "cold_throughput_mbps": 977.3, + "warm_throughput_mbps": 25257.8 + }, + "small_js_read": { + "count": 5000, + "files_sampled": 113, + "bytes_read": 45195287, + "duration_ms": 12.5, + "ops_per_sec": 399176.4, + "throughput_mbps": 3441.0 + }, + "metadata_stat": { + "entries": 6571, + "files": 5557, + "dirs": 670, + "symlinks": 344, + "errors": 0, + "duration_ms": 32.9, + "stats_per_sec": 199915.3 + } + }, + "storage": { + "kernel": { + "cmdline": { + "raw": "console=hvc0 root=/dev/vda ro loglevel=1 quiet init_on_alloc=1 slab_nomerge page_alloc.shuffle=1 random.trust_cpu=1 capsem.storage=virtiofs", + "args": [ + "console=hvc0", + "root=/dev/vda", + "ro", + "loglevel=1", + "quiet", + "init_on_alloc=1", + "slab_nomerge", + "page_alloc.shuffle=1", + "random.trust_cpu=1", + "capsem.storage=virtiofs" + ] + }, + "block_queues": { + "vda": { + "scheduler": "[none] mq-deadline kyber", + "read_ahead_kb": 4096, + "nr_requests": 256, + "rotational": 0, + "logical_block_size": 512, + "physical_block_size": 512, + "max_sectors_kb": 1280, + "nomerges": 0, + "rq_affinity": 1, + "io_poll": 0, + "selected_scheduler": "none" + }, + "vdb": { + "scheduler": "[none] mq-deadline kyber", + "read_ahead_kb": 4096, + "nr_requests": 256, + "rotational": 0, + "logical_block_size": 512, + "physical_block_size": 512, + "max_sectors_kb": 1280, + "nomerges": 0, + "rq_affinity": 1, + "io_poll": 0, + "selected_scheduler": "none" + } + }, + "fuse_connections": {}, + "known_host_queue_sizes": { + "kvm_virtio_blk": 256, + "kvm_virtio_fs": [ + 256, + 256 + ] + } + }, + "mounts": [ + { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + { + "mount_point": "/proc", + "root": "/", + "fs_type": "proc", + "source": "proc", + "options": "rw" + }, + { + "mount_point": "/sys", + "root": "/", + "fs_type": "sysfs", + "source": "sysfs", + "options": "rw" + }, + { + "mount_point": "/dev", + "root": "/", + "fs_type": "devtmpfs", + "source": "devtmpfs", + "options": "rw,size=989876k,nr_inodes=247469,mode=755" + }, + { + "mount_point": "/dev/pts", + "root": "/", + "fs_type": "devpts", + "source": "devpts", + "options": "rw,mode=600,ptmxmode=000" + }, + { + "mount_point": "/root", + "root": "/workspace", + "fs_type": "virtiofs", + "source": "capsem", + "options": "rw" + }, + { + "mount_point": "/etc/resolv.conf", + "root": "/run/resolv.conf", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + } + ], + "paths": { + "/": { + "path": "/", + "exists": true, + "writable": true, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "mode": "drwxr-xr-x", + "statvfs": { + "block_size": 4096, + "fragment_size": 4096, + "blocks": 498138, + "blocks_free": 496821, + "blocks_available": 492725, + "files": 131072, + "files_free": 130886 + } + }, + "/root": { + "path": "/root", + "exists": true, + "writable": true, + "mount": { + "mount_point": "/root", + "root": "/workspace", + "fs_type": "virtiofs", + "source": "capsem", + "options": "rw" + }, + "mode": "drwxr-xr-x", + "statvfs": { + "block_size": 1048576, + "fragment_size": 4096, + "blocks": 975653540, + "blocks_free": 740729211, + "blocks_available": 740729211, + "files": 3862112702, + "files_free": 3859364664 + } + }, + "/tmp": { + "path": "/tmp", + "exists": true, + "writable": true, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "mode": "drwxrwxrwt", + "statvfs": { + "block_size": 4096, + "fragment_size": 4096, + "blocks": 498138, + "blocks_free": 496821, + "blocks_available": 492725, + "files": 131072, + "files_free": 130886 + } + }, + "/var/tmp": { + "path": "/var/tmp", + "exists": true, + "writable": true, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "mode": "drwxrwxrwt", + "statvfs": { + "block_size": 4096, + "fragment_size": 4096, + "blocks": 498138, + "blocks_free": 496821, + "blocks_available": 492725, + "files": 131072, + "files_free": 130886 + } + }, + "/var/log": { + "path": "/var/log", + "exists": true, + "writable": true, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "mode": "drwxr-xr-x", + "statvfs": { + "block_size": 4096, + "fragment_size": 4096, + "blocks": 498138, + "blocks_free": 496821, + "blocks_available": 492725, + "files": 131072, + "files_free": 130886 + } + }, + "/run": { + "path": "/run", + "exists": true, + "writable": true, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "mode": "drwxr-xr-x", + "statvfs": { + "block_size": 4096, + "fragment_size": 4096, + "blocks": 498138, + "blocks_free": 496821, + "blocks_available": 492725, + "files": 131072, + "files_free": 130886 + } + }, + "/usr/bin": { + "path": "/usr/bin", + "exists": true, + "writable": true, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "mode": "drwxr-xr-x", + "statvfs": { + "block_size": 4096, + "fragment_size": 4096, + "blocks": 498138, + "blocks_free": 496821, + "blocks_available": 492725, + "files": 131072, + "files_free": 130886 + } + }, + "/usr/lib": { + "path": "/usr/lib", + "exists": true, + "writable": true, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "mode": "drwxr-xr-x", + "statvfs": { + "block_size": 4096, + "fragment_size": 4096, + "blocks": 498138, + "blocks_free": 496821, + "blocks_available": 492725, + "files": 131072, + "files_free": 130886 + } + }, + "/opt/ai-clis": { + "path": "/opt/ai-clis", + "exists": true, + "writable": true, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "mode": "drwxr-xr-x", + "statvfs": { + "block_size": 4096, + "fragment_size": 4096, + "blocks": 498138, + "blocks_free": 496821, + "blocks_available": 492725, + "files": 131072, + "files_free": 130886 + } + } + }, + "rootfs": { + "scan_dirs": [ + "/usr/bin", + "/usr/lib", + "/opt/ai-clis" + ], + "files_found": 3326, + "largest_file": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "largest_file_size": 238401160, + "backing": { + "root_mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "overlay_lowerdir": "/mnt/a", + "overlay_upperdir": "/mnt/system/upper", + "overlay_workdir": "/mnt/system/work", + "squashfs_mounts": [], + "squashfs_superblock": { + "device": "/dev/vda", + "magic": "0x73717368", + "version": "4.0", + "compression_id": 6, + "compression": "zstd", + "block_size_bytes": 131072, + "block_size": "128.0 KB", + "block_log": 17, + "flags": 192, + "inodes": 32132, + "fragments": 2600, + "mkfs_time": 1780149433, + "id_count": 9, + "read_ahead_kb": 4096 + } + }, + "seq_reads": [ + { + "label": "largest", + "path": "/opt/ai-clis/lib/node_modules/@anthropic-ai/claude-code/bin/claude.exe", + "size_bytes": 238401160, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "cold": { + "size_bytes": 238401160, + "block_size": 1048576, + "duration_ms": 221.8, + "throughput_mbps": 1025.0 + }, + "warm": { + "size_bytes": 238401160, + "block_size": 1048576, + "duration_ms": 9.2, + "throughput_mbps": 24634.6 + } + }, + { + "label": "bash", + "path": "/bin/bash", + "size_bytes": 1346480, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "cold": { + "size_bytes": 1346480, + "block_size": 1048576, + "duration_ms": 0.6, + "throughput_mbps": 2295.3 + }, + "warm": { + "size_bytes": 1346480, + "block_size": 1048576, + "duration_ms": 0.1, + "throughput_mbps": 19456.1 + } + }, + { + "label": "python3", + "path": "/usr/bin/python3", + "size_bytes": 6616880, + "mount": { + "mount_point": "/", + "root": "/", + "fs_type": "overlay", + "source": "overlay", + "options": "rw,lowerdir=/mnt/a,upperdir=/mnt/system/upper,workdir=/mnt/system/work,uuid=on,metacopy=on" + }, + "cold": { + "size_bytes": 6616880, + "block_size": 1048576, + "duration_ms": 4.1, + "throughput_mbps": 1537.0 + }, + "warm": { + "size_bytes": 6616880, + "block_size": 1048576, + "duration_ms": 0.2, + "throughput_mbps": 27426.4 + } + } + ], + "rand_read_4k": { + "count": 2000, + "files_sampled": 1507, + "duration_ms": 338.5, + "iops": 5909.2, + "throughput_mbps": 23.1 + } + }, + "writable": { + "/root": { + "path": "/root", + "size_mb": 64, + "seq_write": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 29.0, + "throughput_mbps": 2204.3 + }, + "seq_read_cold": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 13.8, + "throughput_mbps": 4647.3 + }, + "seq_read_warm": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 13.2, + "throughput_mbps": 4837.0 + }, + "rand_write_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 947.6, + "iops": 10553.2, + "throughput_mbps": 41.2 + }, + "rand_read_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 111.9, + "iops": 89343.4, + "throughput_mbps": 349.0 + }, + "io_profile": { + "path": "/root", + "size_mb": 64, + "random_ops": 2000, + "sequential": { + "4k": { + "write": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 688.4, + "iops": 23800.7, + "throughput_mbps": 93.0, + "avg_latency_ms": 0.042 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 14.7, + "iops": 1114463.1, + "throughput_mbps": 4353.4, + "avg_latency_ms": 0.001 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 14.9, + "iops": 1102294.5, + "throughput_mbps": 4305.8, + "avg_latency_ms": 0.001 + } + }, + "64k": { + "write": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 58.0, + "iops": 17650.5, + "throughput_mbps": 1103.2, + "avg_latency_ms": 0.057 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 12.9, + "iops": 79298.1, + "throughput_mbps": 4956.1, + "avg_latency_ms": 0.013 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 13.1, + "iops": 78274.7, + "throughput_mbps": 4892.2, + "avg_latency_ms": 0.013 + } + }, + "1m": { + "write": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 32.9, + "iops": 1946.9, + "throughput_mbps": 1946.9, + "avg_latency_ms": 0.514 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 12.2, + "iops": 5240.9, + "throughput_mbps": 5240.9, + "avg_latency_ms": 0.191 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 12.8, + "iops": 5014.7, + "throughput_mbps": 5014.7, + "avg_latency_ms": 0.199 + } + } + }, + "random": { + "read_4k": { + "size_bytes": 8192000, + "block_size": 4096, + "count": 2000, + "duration_ms": 29.3, + "iops": 68338.0, + "throughput_mbps": 266.9, + "avg_latency_ms": 0.015, + "latency_ms": { + "p50": 0.013, + "p95": 0.024, + "p99": 0.03, + "max": 0.097 + } + }, + "write_4k_sync": { + "size_bytes": 8192000, + "block_size": 4096, + "count": 2000, + "duration_ms": 167.3, + "iops": 11957.0, + "throughput_mbps": 46.7, + "avg_latency_ms": 0.084, + "latency_ms": { + "p50": 0.072, + "p95": 0.093, + "p99": 0.115, + "max": 5.827 + }, + "sync_each": true + } + } + } + }, + "/tmp": { + "path": "/tmp", + "size_mb": 64, + "seq_write": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 7.7, + "throughput_mbps": 8287.9 + }, + "seq_read_cold": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 6.2, + "throughput_mbps": 10403.6 + }, + "seq_read_warm": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 2.8, + "throughput_mbps": 22761.0 + }, + "rand_write_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 1345.5, + "iops": 7432.0, + "throughput_mbps": 29.0 + }, + "rand_read_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 5.8, + "iops": 1714396.0, + "throughput_mbps": 6696.9 + }, + "io_profile": { + "path": "/tmp", + "size_mb": 64, + "random_ops": 2000, + "sequential": { + "4k": { + "write": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 10.6, + "iops": 1549644.1, + "throughput_mbps": 6053.3, + "avg_latency_ms": 0.001 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 8.7, + "iops": 1894076.7, + "throughput_mbps": 7398.7, + "avg_latency_ms": 0.001 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 6.4, + "iops": 2548419.0, + "throughput_mbps": 9954.8, + "avg_latency_ms": 0.0 + } + }, + "64k": { + "write": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 7.6, + "iops": 133908.7, + "throughput_mbps": 8369.3, + "avg_latency_ms": 0.007 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 5.5, + "iops": 187211.5, + "throughput_mbps": 11700.7, + "avg_latency_ms": 0.005 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 3.1, + "iops": 326326.9, + "throughput_mbps": 20395.4, + "avg_latency_ms": 0.003 + } + }, + "1m": { + "write": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 7.9, + "iops": 8086.3, + "throughput_mbps": 8086.3, + "avg_latency_ms": 0.124 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 5.5, + "iops": 11589.9, + "throughput_mbps": 11589.9, + "avg_latency_ms": 0.086 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 2.9, + "iops": 22337.9, + "throughput_mbps": 22337.9, + "avg_latency_ms": 0.045 + } + } + }, + "random": { + "read_4k": { + "size_bytes": 8192000, + "block_size": 4096, + "count": 2000, + "duration_ms": 38.9, + "iops": 51448.0, + "throughput_mbps": 201.0, + "avg_latency_ms": 0.019, + "latency_ms": { + "p50": 0.02, + "p95": 0.026, + "p99": 0.031, + "max": 0.06 + } + }, + "write_4k_sync": { + "size_bytes": 8192000, + "block_size": 4096, + "count": 2000, + "duration_ms": 80.5, + "iops": 24833.0, + "throughput_mbps": 97.0, + "avg_latency_ms": 0.04, + "latency_ms": { + "p50": 0.038, + "p95": 0.05, + "p99": 0.137, + "max": 0.212 + }, + "sync_each": true + } + } + } + }, + "/var/tmp": { + "path": "/var/tmp", + "size_mb": 64, + "seq_write": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 7.7, + "throughput_mbps": 8353.3 + }, + "seq_read_cold": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 6.0, + "throughput_mbps": 10594.3 + }, + "seq_read_warm": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 2.9, + "throughput_mbps": 22281.5 + }, + "rand_write_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 1315.4, + "iops": 7602.4, + "throughput_mbps": 29.7 + }, + "rand_read_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 5.6, + "iops": 1784081.5, + "throughput_mbps": 6969.1 + }, + "io_profile": { + "path": "/var/tmp", + "size_mb": 64, + "random_ops": 2000, + "sequential": { + "4k": { + "write": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 10.3, + "iops": 1588482.0, + "throughput_mbps": 6205.0, + "avg_latency_ms": 0.001 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 8.2, + "iops": 1999328.8, + "throughput_mbps": 7809.9, + "avg_latency_ms": 0.001 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 6.3, + "iops": 2584413.8, + "throughput_mbps": 10095.4, + "avg_latency_ms": 0.0 + } + }, + "64k": { + "write": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 8.0, + "iops": 128344.9, + "throughput_mbps": 8021.6, + "avg_latency_ms": 0.008 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 5.4, + "iops": 191287.2, + "throughput_mbps": 11955.4, + "avg_latency_ms": 0.005 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 3.3, + "iops": 308879.6, + "throughput_mbps": 19305.0, + "avg_latency_ms": 0.003 + } + }, + "1m": { + "write": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 7.8, + "iops": 8199.2, + "throughput_mbps": 8199.2, + "avg_latency_ms": 0.122 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 5.7, + "iops": 11190.0, + "throughput_mbps": 11190.0, + "avg_latency_ms": 0.089 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 3.1, + "iops": 20319.9, + "throughput_mbps": 20319.9, + "avg_latency_ms": 0.049 + } + } + }, + "random": { + "read_4k": { + "size_bytes": 8192000, + "block_size": 4096, + "count": 2000, + "duration_ms": 39.3, + "iops": 50866.2, + "throughput_mbps": 198.7, + "avg_latency_ms": 0.02, + "latency_ms": { + "p50": 0.02, + "p95": 0.027, + "p99": 0.032, + "max": 0.075 + } + }, + "write_4k_sync": { + "size_bytes": 8192000, + "block_size": 4096, + "count": 2000, + "duration_ms": 81.0, + "iops": 24703.2, + "throughput_mbps": 96.5, + "avg_latency_ms": 0.04, + "latency_ms": { + "p50": 0.038, + "p95": 0.05, + "p99": 0.139, + "max": 0.205 + }, + "sync_each": true + } + } + } + }, + "/var/log": { + "path": "/var/log", + "size_mb": 64, + "seq_write": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 8.3, + "throughput_mbps": 7744.5 + }, + "seq_read_cold": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 6.0, + "throughput_mbps": 10607.7 + }, + "seq_read_warm": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 3.0, + "throughput_mbps": 21018.4 + }, + "rand_write_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 1379.5, + "iops": 7248.8, + "throughput_mbps": 28.3 + }, + "rand_read_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 5.7, + "iops": 1752400.5, + "throughput_mbps": 6845.3 + }, + "io_profile": { + "path": "/var/log", + "size_mb": 64, + "random_ops": 2000, + "sequential": { + "4k": { + "write": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 11.0, + "iops": 1492190.1, + "throughput_mbps": 5828.9, + "avg_latency_ms": 0.001 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 8.4, + "iops": 1961353.1, + "throughput_mbps": 7661.5, + "avg_latency_ms": 0.001 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 6.4, + "iops": 2559383.3, + "throughput_mbps": 9997.6, + "avg_latency_ms": 0.0 + } + }, + "64k": { + "write": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 8.6, + "iops": 118856.7, + "throughput_mbps": 7428.5, + "avg_latency_ms": 0.008 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 5.5, + "iops": 185491.8, + "throughput_mbps": 11593.2, + "avg_latency_ms": 0.005 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 3.2, + "iops": 324084.9, + "throughput_mbps": 20255.3, + "avg_latency_ms": 0.003 + } + }, + "1m": { + "write": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 8.6, + "iops": 7414.4, + "throughput_mbps": 7414.4, + "avg_latency_ms": 0.135 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 5.4, + "iops": 11802.6, + "throughput_mbps": 11802.6, + "avg_latency_ms": 0.085 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 3.0, + "iops": 21595.8, + "throughput_mbps": 21595.8, + "avg_latency_ms": 0.046 + } + } + }, + "random": { + "read_4k": { + "size_bytes": 8192000, + "block_size": 4096, + "count": 2000, + "duration_ms": 39.5, + "iops": 50639.3, + "throughput_mbps": 197.8, + "avg_latency_ms": 0.02, + "latency_ms": { + "p50": 0.02, + "p95": 0.027, + "p99": 0.032, + "max": 0.062 + } + }, + "write_4k_sync": { + "size_bytes": 8192000, + "block_size": 4096, + "count": 2000, + "duration_ms": 82.7, + "iops": 24181.8, + "throughput_mbps": 94.5, + "avg_latency_ms": 0.041, + "latency_ms": { + "p50": 0.038, + "p95": 0.052, + "p99": 0.11, + "max": 0.274 + }, + "sync_each": true + } + } + } + }, + "/run": { + "path": "/run", + "size_mb": 64, + "seq_write": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 10.7, + "throughput_mbps": 5994.9 + }, + "seq_read_cold": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 6.7, + "throughput_mbps": 9488.8 + }, + "seq_read_warm": { + "size_bytes": 67108864, + "block_size": 1048576, + "duration_ms": 3.1, + "throughput_mbps": 20661.3 + }, + "rand_write_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 1037.8, + "iops": 9635.4, + "throughput_mbps": 37.6 + }, + "rand_read_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 7.1, + "iops": 1413419.4, + "throughput_mbps": 5521.2 + }, + "io_profile": { + "path": "/run", + "size_mb": 64, + "random_ops": 2000, + "sequential": { + "4k": { + "write": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 11.1, + "iops": 1470142.2, + "throughput_mbps": 5742.7, + "avg_latency_ms": 0.001 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 8.6, + "iops": 1897320.9, + "throughput_mbps": 7411.4, + "avg_latency_ms": 0.001 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 4096, + "count": 16384, + "duration_ms": 6.6, + "iops": 2481124.3, + "throughput_mbps": 9691.9, + "avg_latency_ms": 0.0 + } + }, + "64k": { + "write": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 8.3, + "iops": 123198.5, + "throughput_mbps": 7699.9, + "avg_latency_ms": 0.008 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 5.4, + "iops": 189500.9, + "throughput_mbps": 11843.8, + "avg_latency_ms": 0.005 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 65536, + "count": 1024, + "duration_ms": 3.3, + "iops": 307257.6, + "throughput_mbps": 19203.6, + "avg_latency_ms": 0.003 + } + }, + "1m": { + "write": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 8.6, + "iops": 7477.3, + "throughput_mbps": 7477.3, + "avg_latency_ms": 0.134 + }, + "read_cold": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 5.7, + "iops": 11317.2, + "throughput_mbps": 11317.2, + "avg_latency_ms": 0.088 + }, + "read_warm": { + "size_bytes": 67108864, + "block_size": 1048576, + "count": 64, + "duration_ms": 3.0, + "iops": 21481.0, + "throughput_mbps": 21481.0, + "avg_latency_ms": 0.047 + } + } + }, + "random": { + "read_4k": { + "size_bytes": 8192000, + "block_size": 4096, + "count": 2000, + "duration_ms": 39.6, + "iops": 50553.5, + "throughput_mbps": 197.5, + "avg_latency_ms": 0.02, + "latency_ms": { + "p50": 0.02, + "p95": 0.027, + "p99": 0.032, + "max": 0.069 + } + }, + "write_4k_sync": { + "size_bytes": 8192000, + "block_size": 4096, + "count": 2000, + "duration_ms": 80.0, + "iops": 25009.5, + "throughput_mbps": 97.7, + "avg_latency_ms": 0.04, + "latency_ms": { + "p50": 0.038, + "p95": 0.049, + "p99": 0.094, + "max": 0.137 + }, + "sync_each": true + } + } + } + } + } + }, + "startup": { + "runs_per_command": 3, + "commands": { + "python3": { + "command": [ + "python3", + "--version" + ], + "timings_ms": [ + 9.4, + 7.3, + 7.6 + ], + "min_ms": 7.3, + "mean_ms": 8.1, + "max_ms": 9.4 + }, + "node": { + "command": [ + "node", + "--version" + ], + "timings_ms": [ + 75.4, + 79.4, + 78.0 + ], + "min_ms": 75.4, + "mean_ms": 77.6, + "max_ms": 79.4 + }, + "claude": { + "command": [ + "claude", + "--version" + ], + "timings_ms": [ + 343.2, + 291.9, + 292.0 + ], + "min_ms": 291.9, + "mean_ms": 309.0, + "max_ms": 343.2 + }, + "gemini": { + "command": [ + "gemini", + "--version" + ], + "timings_ms": [ + 859.3, + 802.5, + 809.4 + ], + "min_ms": 802.5, + "mean_ms": 823.7, + "max_ms": 859.3 + }, + "codex": { + "command": [ + "codex", + "--version" + ], + "timings_ms": [ + 229.5, + 240.9, + 241.0 + ], + "min_ms": 229.5, + "mean_ms": 237.1, + "max_ms": 241.0 + } + } + }, + "http": { + "url": "https://www.google.com/", + "total_requests": 50, + "concurrency": 5, + "successful": 50, + "failed": 0, + "total_duration_ms": 760.5, + "requests_per_sec": 65.7, + "transfer_bytes": 4010697, + "latency_ms": { + "min": 50.6, + "max": 198.8, + "mean": 75.4, + "p50": 60.3, + "p95": 186.3, + "p99": 196.4 + } + }, + "throughput": { + "url": "https://cdn.elie.net/static/files/i-am-a-legend/i-am-a-legend-slides.pdf", + "http_code": 200, + "size_bytes": 9984968, + "duration_s": 0.51, + "throughput_mbps": 18.69 + }, + "snapshot": { + "10_files": { + "create_ms": 760.6, + "create_ok": true, + "list_ms": 287.6, + "list_ok": true, + "changes_ms": 280.4, + "changes_ok": true, + "revert_ms": 307.4, + "revert_ok": true, + "delete_ms": 313.8, + "delete_ok": true + }, + "100_files": { + "create_ms": 302.7, + "create_ok": true, + "list_ms": 285.5, + "list_ok": true, + "changes_ms": 298.0, + "changes_ok": true, + "revert_ms": 314.9, + "revert_ok": true, + "delete_ms": 282.6, + "delete_ok": true + }, + "500_files": { + "create_ms": 308.3, + "create_ok": true, + "list_ms": 308.9, + "list_ok": true, + "changes_ms": 336.0, + "changes_ok": true, + "revert_ms": 304.8, + "revert_ok": true, + "delete_ms": 301.6, + "delete_ok": true + } + }, + "schema": "capsem.benchmark-artifact.v1", + "project_version": "1.2.1780103109", + "arch": "arm64", + "recorded_at": 1780149836.1162949, + "recorded_at_utc": "2026-05-30T14:03:56.116298+00:00", + "command": "capsem-bench all", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": true, + "source_dirty": false, + "dirty_paths": [ + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json" + ] + } +} \ No newline at end of file diff --git a/benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json b/benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json index 0e93bcf4..8fe51c70 100644 --- a/benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json +++ b/benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json @@ -1,6 +1,6 @@ { "version": "0.1.0", - "timestamp": 1780103291.448705, + "timestamp": 1780149845.194092, "vm_count": 8, "iterations": { "service_global": 16, @@ -25,687 +25,687 @@ "service_global": { "/version": { "count": 16, - "min_ms": 0.051, - "p50_ms": 0.056, - "p95_ms": 0.088, - "p99_ms": 0.088, - "max_ms": 0.088 + "min_ms": 0.145, + "p50_ms": 0.17, + "p95_ms": 0.245, + "p99_ms": 0.245, + "max_ms": 0.245 }, "/list": { "count": 16, - "min_ms": 0.177, - "p50_ms": 0.191, - "p95_ms": 0.335, - "p99_ms": 0.335, - "max_ms": 0.335 + "min_ms": 0.577, + "p50_ms": 0.607, + "p95_ms": 0.684, + "p99_ms": 0.684, + "max_ms": 0.684 }, "/stats": { "count": 16, - "min_ms": 0.51, - "p50_ms": 0.602, - "p95_ms": 0.798, - "p99_ms": 0.798, - "max_ms": 0.798 + "min_ms": 2.526, + "p50_ms": 2.713, + "p95_ms": 2.913, + "p99_ms": 2.913, + "max_ms": 2.913 }, "/settings": { "count": 16, - "min_ms": 0.234, - "p50_ms": 0.253, - "p95_ms": 0.338, - "p99_ms": 0.338, - "max_ms": 0.338 + "min_ms": 1.218, + "p50_ms": 1.338, + "p95_ms": 1.474, + "p99_ms": 1.474, + "max_ms": 1.474 }, "/settings/presets": { "count": 16, - "min_ms": 0.204, - "p50_ms": 0.23, - "p95_ms": 0.421, - "p99_ms": 0.421, - "max_ms": 0.421 + "min_ms": 0.86, + "p50_ms": 0.909, + "p95_ms": 1.041, + "p99_ms": 1.041, + "max_ms": 1.041 }, "/profiles": { "count": 16, - "min_ms": 0.369, - "p50_ms": 0.406, - "p95_ms": 0.532, - "p99_ms": 0.532, - "max_ms": 0.532 + "min_ms": 1.713, + "p50_ms": 1.771, + "p95_ms": 2.022, + "p99_ms": 2.022, + "max_ms": 2.022 }, "/profiles/catalog": { "count": 16, - "min_ms": 0.094, - "p50_ms": 0.105, - "p95_ms": 0.123, - "p99_ms": 0.123, - "max_ms": 0.123 + "min_ms": 0.252, + "p50_ms": 0.278, + "p95_ms": 0.34, + "p99_ms": 0.34, + "max_ms": 0.34 }, "/rules": { "count": 16, - "min_ms": 0.169, - "p50_ms": 0.182, - "p95_ms": 0.213, - "p99_ms": 0.213, - "max_ms": 0.213 + "min_ms": 0.86, + "p50_ms": 0.9, + "p95_ms": 1.12, + "p99_ms": 1.12, + "max_ms": 1.12 }, "/enforcement": { "count": 16, - "min_ms": 0.061, - "p50_ms": 0.071, - "p95_ms": 0.089, - "p99_ms": 0.089, - "max_ms": 0.089 + "min_ms": 0.288, + "p50_ms": 0.304, + "p95_ms": 0.33, + "p99_ms": 0.33, + "max_ms": 0.33 }, "/enforcement/stats": { "count": 16, - "min_ms": 0.302, - "p50_ms": 0.323, - "p95_ms": 0.409, - "p99_ms": 0.409, - "max_ms": 0.409 + "min_ms": 0.698, + "p50_ms": 0.786, + "p95_ms": 0.977, + "p99_ms": 0.977, + "max_ms": 0.977 }, "/detection": { "count": 16, - "min_ms": 0.044, - "p50_ms": 0.052, - "p95_ms": 0.059, - "p99_ms": 0.059, - "max_ms": 0.059 + "min_ms": 0.141, + "p50_ms": 0.149, + "p95_ms": 0.164, + "p99_ms": 0.164, + "max_ms": 0.164 }, "/detection/stats": { "count": 16, - "min_ms": 0.297, - "p50_ms": 0.32, - "p95_ms": 0.457, - "p99_ms": 0.457, - "max_ms": 0.457 + "min_ms": 0.535, + "p50_ms": 0.6, + "p95_ms": 0.664, + "p99_ms": 0.664, + "max_ms": 0.664 }, "/confirm/pending": { "count": 16, - "min_ms": 0.045, - "p50_ms": 0.049, - "p95_ms": 0.057, - "p99_ms": 0.057, - "max_ms": 0.057 + "min_ms": 0.151, + "p50_ms": 0.16, + "p95_ms": 0.186, + "p99_ms": 0.186, + "max_ms": 0.186 }, "/skills": { "count": 16, - "min_ms": 0.205, - "p50_ms": 0.228, - "p95_ms": 0.315, - "p99_ms": 0.315, - "max_ms": 0.315 + "min_ms": 0.857, + "p50_ms": 0.891, + "p95_ms": 1.045, + "p99_ms": 1.045, + "max_ms": 1.045 }, "/setup/state": { "count": 16, - "min_ms": 0.063, - "p50_ms": 0.068, - "p95_ms": 0.084, - "p99_ms": 0.084, - "max_ms": 0.084 + "min_ms": 0.169, + "p50_ms": 0.18, + "p95_ms": 0.2, + "p99_ms": 0.2, + "max_ms": 0.2 }, "/setup/assets": { "count": 16, - "min_ms": 0.067, - "p50_ms": 0.073, - "p95_ms": 0.086, - "p99_ms": 0.086, - "max_ms": 0.086 + "min_ms": 0.218, + "p50_ms": 0.233, + "p95_ms": 0.26, + "p99_ms": 0.26, + "max_ms": 0.26 }, "/mcp/connectors": { "count": 16, - "min_ms": 0.193, - "p50_ms": 0.214, - "p95_ms": 0.235, - "p99_ms": 0.235, - "max_ms": 0.235 + "min_ms": 0.85, + "p50_ms": 0.885, + "p95_ms": 0.92, + "p99_ms": 0.92, + "max_ms": 0.92 } }, "service_vm": { - "/info/epbench-937920e0-0": { - "count": 4, - "min_ms": 0.435, - "p50_ms": 0.468, - "p95_ms": 0.629, - "p99_ms": 0.629, - "max_ms": 0.629 - }, - "/logs/epbench-937920e0-0": { + "/info/epbench-a133189f-0": { "count": 4, - "min_ms": 0.619, - "p50_ms": 0.656, - "p95_ms": 0.745, - "p99_ms": 0.745, - "max_ms": 0.745 + "min_ms": 0.928, + "p50_ms": 0.96, + "p95_ms": 1.15, + "p99_ms": 1.15, + "max_ms": 1.15 }, - "/history/epbench-937920e0-0": { + "/logs/epbench-a133189f-0": { "count": 4, - "min_ms": 0.304, - "p50_ms": 0.322, - "p95_ms": 0.371, - "p99_ms": 0.371, - "max_ms": 0.371 + "min_ms": 3.189, + "p50_ms": 3.237, + "p95_ms": 3.275, + "p99_ms": 3.275, + "max_ms": 3.275 }, - "/history/epbench-937920e0-0/counts": { + "/history/epbench-a133189f-0": { "count": 4, - "min_ms": 0.301, - "p50_ms": 0.304, - "p95_ms": 0.343, - "p99_ms": 0.343, - "max_ms": 0.343 + "min_ms": 0.846, + "p50_ms": 0.898, + "p95_ms": 0.911, + "p99_ms": 0.911, + "max_ms": 0.911 }, - "/history/epbench-937920e0-0/processes": { + "/history/epbench-a133189f-0/counts": { "count": 4, - "min_ms": 0.296, - "p50_ms": 0.319, - "p95_ms": 0.35, - "p99_ms": 0.35, - "max_ms": 0.35 + "min_ms": 0.62, + "p50_ms": 0.62, + "p95_ms": 0.696, + "p99_ms": 0.696, + "max_ms": 0.696 }, - "/history/epbench-937920e0-0/transcript": { + "/history/epbench-a133189f-0/processes": { "count": 4, - "min_ms": 0.067, - "p50_ms": 0.07, - "p95_ms": 0.081, - "p99_ms": 0.081, - "max_ms": 0.081 + "min_ms": 0.655, + "p50_ms": 0.688, + "p95_ms": 0.747, + "p99_ms": 0.747, + "max_ms": 0.747 }, - "/files/epbench-937920e0-0": { + "/history/epbench-a133189f-0/transcript": { "count": 4, - "min_ms": 2.06, - "p50_ms": 2.247, - "p95_ms": 2.491, - "p99_ms": 2.491, - "max_ms": 2.491 + "min_ms": 0.18, + "p50_ms": 0.18, + "p95_ms": 0.199, + "p99_ms": 0.199, + "max_ms": 0.199 }, - "/sessions/epbench-937920e0-0/policy-contexts": { + "/files/epbench-a133189f-0": { "count": 4, - "min_ms": 0.616, - "p50_ms": 0.67, - "p95_ms": 0.737, - "p99_ms": 0.737, - "max_ms": 0.737 + "min_ms": 2.394, + "p50_ms": 2.471, + "p95_ms": 2.694, + "p99_ms": 2.694, + "max_ms": 2.694 }, - "/info/epbench-937920e0-1": { + "/sessions/epbench-a133189f-0/policy-contexts": { "count": 4, - "min_ms": 0.361, - "p50_ms": 0.372, - "p95_ms": 0.449, - "p99_ms": 0.449, - "max_ms": 0.449 + "min_ms": 2.281, + "p50_ms": 2.287, + "p95_ms": 2.411, + "p99_ms": 2.411, + "max_ms": 2.411 }, - "/logs/epbench-937920e0-1": { + "/info/epbench-a133189f-1": { "count": 4, - "min_ms": 0.588, - "p50_ms": 0.609, - "p95_ms": 0.66, - "p99_ms": 0.66, - "max_ms": 0.66 + "min_ms": 0.96, + "p50_ms": 1.017, + "p95_ms": 1.081, + "p99_ms": 1.081, + "max_ms": 1.081 }, - "/history/epbench-937920e0-1": { + "/logs/epbench-a133189f-1": { "count": 4, - "min_ms": 0.334, - "p50_ms": 0.371, - "p95_ms": 0.418, - "p99_ms": 0.418, - "max_ms": 0.418 + "min_ms": 3.154, + "p50_ms": 3.188, + "p95_ms": 3.221, + "p99_ms": 3.221, + "max_ms": 3.221 }, - "/history/epbench-937920e0-1/counts": { + "/history/epbench-a133189f-1": { "count": 4, - "min_ms": 0.278, - "p50_ms": 0.282, - "p95_ms": 0.327, - "p99_ms": 0.327, - "max_ms": 0.327 + "min_ms": 0.852, + "p50_ms": 0.854, + "p95_ms": 0.931, + "p99_ms": 0.931, + "max_ms": 0.931 }, - "/history/epbench-937920e0-1/processes": { + "/history/epbench-a133189f-1/counts": { "count": 4, - "min_ms": 0.313, - "p50_ms": 0.322, - "p95_ms": 0.345, - "p99_ms": 0.345, - "max_ms": 0.345 - }, - "/history/epbench-937920e0-1/transcript": { - "count": 4, - "min_ms": 0.062, - "p50_ms": 0.065, - "p95_ms": 0.074, - "p99_ms": 0.074, - "max_ms": 0.074 + "min_ms": 0.594, + "p50_ms": 0.597, + "p95_ms": 0.61, + "p99_ms": 0.61, + "max_ms": 0.61 }, - "/files/epbench-937920e0-1": { + "/history/epbench-a133189f-1/processes": { "count": 4, - "min_ms": 2.17, - "p50_ms": 2.195, - "p95_ms": 2.28, - "p99_ms": 2.28, - "max_ms": 2.28 + "min_ms": 0.642, + "p50_ms": 0.649, + "p95_ms": 0.685, + "p99_ms": 0.685, + "max_ms": 0.685 }, - "/sessions/epbench-937920e0-1/policy-contexts": { + "/history/epbench-a133189f-1/transcript": { "count": 4, - "min_ms": 0.582, - "p50_ms": 0.621, - "p95_ms": 0.648, - "p99_ms": 0.648, - "max_ms": 0.648 + "min_ms": 0.18, + "p50_ms": 0.183, + "p95_ms": 0.195, + "p99_ms": 0.195, + "max_ms": 0.195 }, - "/info/epbench-937920e0-2": { + "/files/epbench-a133189f-1": { "count": 4, - "min_ms": 0.374, - "p50_ms": 0.4, - "p95_ms": 0.434, - "p99_ms": 0.434, - "max_ms": 0.434 + "min_ms": 2.359, + "p50_ms": 2.456, + "p95_ms": 2.535, + "p99_ms": 2.535, + "max_ms": 2.535 }, - "/logs/epbench-937920e0-2": { + "/sessions/epbench-a133189f-1/policy-contexts": { "count": 4, - "min_ms": 0.616, - "p50_ms": 0.647, - "p95_ms": 0.688, - "p99_ms": 0.688, - "max_ms": 0.688 + "min_ms": 2.255, + "p50_ms": 2.298, + "p95_ms": 2.487, + "p99_ms": 2.487, + "max_ms": 2.487 }, - "/history/epbench-937920e0-2": { + "/info/epbench-a133189f-2": { "count": 4, - "min_ms": 0.305, - "p50_ms": 0.318, - "p95_ms": 0.333, - "p99_ms": 0.333, - "max_ms": 0.333 + "min_ms": 0.894, + "p50_ms": 0.985, + "p95_ms": 1.024, + "p99_ms": 1.024, + "max_ms": 1.024 }, - "/history/epbench-937920e0-2/counts": { + "/logs/epbench-a133189f-2": { "count": 4, - "min_ms": 0.278, - "p50_ms": 0.284, - "p95_ms": 0.312, - "p99_ms": 0.312, - "max_ms": 0.312 + "min_ms": 2.979, + "p50_ms": 3.068, + "p95_ms": 3.265, + "p99_ms": 3.265, + "max_ms": 3.265 }, - "/history/epbench-937920e0-2/processes": { + "/history/epbench-a133189f-2": { "count": 4, - "min_ms": 0.282, - "p50_ms": 0.282, - "p95_ms": 0.325, - "p99_ms": 0.325, - "max_ms": 0.325 + "min_ms": 0.826, + "p50_ms": 0.837, + "p95_ms": 0.902, + "p99_ms": 0.902, + "max_ms": 0.902 }, - "/history/epbench-937920e0-2/transcript": { + "/history/epbench-a133189f-2/counts": { "count": 4, - "min_ms": 0.066, - "p50_ms": 0.066, - "p95_ms": 0.077, - "p99_ms": 0.077, - "max_ms": 0.077 + "min_ms": 0.605, + "p50_ms": 0.621, + "p95_ms": 0.677, + "p99_ms": 0.677, + "max_ms": 0.677 }, - "/files/epbench-937920e0-2": { + "/history/epbench-a133189f-2/processes": { "count": 4, - "min_ms": 2.188, - "p50_ms": 2.251, - "p95_ms": 2.318, - "p99_ms": 2.318, - "max_ms": 2.318 + "min_ms": 0.641, + "p50_ms": 0.65, + "p95_ms": 0.711, + "p99_ms": 0.711, + "max_ms": 0.711 }, - "/sessions/epbench-937920e0-2/policy-contexts": { + "/history/epbench-a133189f-2/transcript": { "count": 4, - "min_ms": 0.599, - "p50_ms": 0.638, - "p95_ms": 0.719, - "p99_ms": 0.719, - "max_ms": 0.719 + "min_ms": 0.187, + "p50_ms": 0.192, + "p95_ms": 0.201, + "p99_ms": 0.201, + "max_ms": 0.201 }, - "/info/epbench-937920e0-3": { + "/files/epbench-a133189f-2": { "count": 4, - "min_ms": 0.378, - "p50_ms": 0.419, - "p95_ms": 0.497, - "p99_ms": 0.497, - "max_ms": 0.497 + "min_ms": 2.398, + "p50_ms": 2.404, + "p95_ms": 2.535, + "p99_ms": 2.535, + "max_ms": 2.535 }, - "/logs/epbench-937920e0-3": { + "/sessions/epbench-a133189f-2/policy-contexts": { "count": 4, - "min_ms": 0.604, - "p50_ms": 0.616, - "p95_ms": 0.69, - "p99_ms": 0.69, - "max_ms": 0.69 + "min_ms": 2.299, + "p50_ms": 2.306, + "p95_ms": 2.338, + "p99_ms": 2.338, + "max_ms": 2.338 }, - "/history/epbench-937920e0-3": { + "/info/epbench-a133189f-3": { "count": 4, - "min_ms": 0.324, - "p50_ms": 0.326, - "p95_ms": 0.374, - "p99_ms": 0.374, - "max_ms": 0.374 + "min_ms": 0.865, + "p50_ms": 0.946, + "p95_ms": 0.993, + "p99_ms": 0.993, + "max_ms": 0.993 }, - "/history/epbench-937920e0-3/counts": { + "/logs/epbench-a133189f-3": { "count": 4, - "min_ms": 0.264, - "p50_ms": 0.278, - "p95_ms": 0.348, - "p99_ms": 0.348, - "max_ms": 0.348 + "min_ms": 3.048, + "p50_ms": 3.164, + "p95_ms": 3.247, + "p99_ms": 3.247, + "max_ms": 3.247 }, - "/history/epbench-937920e0-3/processes": { + "/history/epbench-a133189f-3": { "count": 4, - "min_ms": 0.279, - "p50_ms": 0.314, - "p95_ms": 0.331, - "p99_ms": 0.331, - "max_ms": 0.331 + "min_ms": 0.824, + "p50_ms": 0.841, + "p95_ms": 0.861, + "p99_ms": 0.861, + "max_ms": 0.861 }, - "/history/epbench-937920e0-3/transcript": { + "/history/epbench-a133189f-3/counts": { "count": 4, - "min_ms": 0.063, - "p50_ms": 0.067, - "p95_ms": 0.074, - "p99_ms": 0.074, - "max_ms": 0.074 + "min_ms": 0.602, + "p50_ms": 0.604, + "p95_ms": 0.642, + "p99_ms": 0.642, + "max_ms": 0.642 }, - "/files/epbench-937920e0-3": { + "/history/epbench-a133189f-3/processes": { "count": 4, - "min_ms": 2.125, - "p50_ms": 2.19, - "p95_ms": 2.263, - "p99_ms": 2.263, - "max_ms": 2.263 + "min_ms": 0.652, + "p50_ms": 0.704, + "p95_ms": 0.721, + "p99_ms": 0.721, + "max_ms": 0.721 }, - "/sessions/epbench-937920e0-3/policy-contexts": { + "/history/epbench-a133189f-3/transcript": { "count": 4, - "min_ms": 0.594, - "p50_ms": 0.636, - "p95_ms": 0.648, - "p99_ms": 0.648, - "max_ms": 0.648 + "min_ms": 0.193, + "p50_ms": 0.199, + "p95_ms": 0.207, + "p99_ms": 0.207, + "max_ms": 0.207 }, - "/info/epbench-937920e0-4": { + "/files/epbench-a133189f-3": { "count": 4, - "min_ms": 0.401, - "p50_ms": 0.42, - "p95_ms": 0.434, - "p99_ms": 0.434, - "max_ms": 0.434 + "min_ms": 2.373, + "p50_ms": 2.422, + "p95_ms": 2.456, + "p99_ms": 2.456, + "max_ms": 2.456 }, - "/logs/epbench-937920e0-4": { + "/sessions/epbench-a133189f-3/policy-contexts": { "count": 4, - "min_ms": 0.571, - "p50_ms": 0.606, - "p95_ms": 0.68, - "p99_ms": 0.68, - "max_ms": 0.68 + "min_ms": 2.239, + "p50_ms": 2.286, + "p95_ms": 2.387, + "p99_ms": 2.387, + "max_ms": 2.387 }, - "/history/epbench-937920e0-4": { + "/info/epbench-a133189f-4": { "count": 4, - "min_ms": 0.314, - "p50_ms": 0.334, - "p95_ms": 0.348, - "p99_ms": 0.348, - "max_ms": 0.348 + "min_ms": 0.923, + "p50_ms": 0.95, + "p95_ms": 1.015, + "p99_ms": 1.015, + "max_ms": 1.015 }, - "/history/epbench-937920e0-4/counts": { + "/logs/epbench-a133189f-4": { "count": 4, - "min_ms": 0.263, - "p50_ms": 0.267, - "p95_ms": 0.31, - "p99_ms": 0.31, - "max_ms": 0.31 + "min_ms": 3.041, + "p50_ms": 3.061, + "p95_ms": 3.176, + "p99_ms": 3.176, + "max_ms": 3.176 }, - "/history/epbench-937920e0-4/processes": { + "/history/epbench-a133189f-4": { "count": 4, - "min_ms": 0.272, - "p50_ms": 0.276, - "p95_ms": 0.333, - "p99_ms": 0.333, - "max_ms": 0.333 + "min_ms": 0.851, + "p50_ms": 0.856, + "p95_ms": 0.897, + "p99_ms": 0.897, + "max_ms": 0.897 }, - "/history/epbench-937920e0-4/transcript": { + "/history/epbench-a133189f-4/counts": { "count": 4, - "min_ms": 0.065, - "p50_ms": 0.065, - "p95_ms": 0.078, - "p99_ms": 0.078, - "max_ms": 0.078 + "min_ms": 0.592, + "p50_ms": 0.61, + "p95_ms": 0.629, + "p99_ms": 0.629, + "max_ms": 0.629 }, - "/files/epbench-937920e0-4": { + "/history/epbench-a133189f-4/processes": { "count": 4, - "min_ms": 2.109, - "p50_ms": 2.198, - "p95_ms": 2.323, - "p99_ms": 2.323, - "max_ms": 2.323 + "min_ms": 0.669, + "p50_ms": 0.693, + "p95_ms": 0.762, + "p99_ms": 0.762, + "max_ms": 0.762 }, - "/sessions/epbench-937920e0-4/policy-contexts": { + "/history/epbench-a133189f-4/transcript": { "count": 4, - "min_ms": 0.582, - "p50_ms": 0.615, - "p95_ms": 0.767, - "p99_ms": 0.767, - "max_ms": 0.767 + "min_ms": 0.2, + "p50_ms": 0.203, + "p95_ms": 0.225, + "p99_ms": 0.225, + "max_ms": 0.225 }, - "/info/epbench-937920e0-5": { + "/files/epbench-a133189f-4": { "count": 4, - "min_ms": 0.383, - "p50_ms": 0.411, - "p95_ms": 0.414, - "p99_ms": 0.414, - "max_ms": 0.414 + "min_ms": 2.355, + "p50_ms": 2.447, + "p95_ms": 2.57, + "p99_ms": 2.57, + "max_ms": 2.57 }, - "/logs/epbench-937920e0-5": { + "/sessions/epbench-a133189f-4/policy-contexts": { "count": 4, - "min_ms": 0.576, - "p50_ms": 0.61, - "p95_ms": 0.697, - "p99_ms": 0.697, - "max_ms": 0.697 + "min_ms": 2.27, + "p50_ms": 2.317, + "p95_ms": 2.376, + "p99_ms": 2.376, + "max_ms": 2.376 }, - "/history/epbench-937920e0-5": { + "/info/epbench-a133189f-5": { "count": 4, - "min_ms": 0.32, - "p50_ms": 0.337, - "p95_ms": 0.355, - "p99_ms": 0.355, - "max_ms": 0.355 + "min_ms": 0.929, + "p50_ms": 0.93, + "p95_ms": 0.943, + "p99_ms": 0.943, + "max_ms": 0.943 }, - "/history/epbench-937920e0-5/counts": { + "/logs/epbench-a133189f-5": { "count": 4, - "min_ms": 0.274, - "p50_ms": 0.28, - "p95_ms": 0.342, - "p99_ms": 0.342, - "max_ms": 0.342 + "min_ms": 3.047, + "p50_ms": 3.093, + "p95_ms": 3.174, + "p99_ms": 3.174, + "max_ms": 3.174 }, - "/history/epbench-937920e0-5/processes": { + "/history/epbench-a133189f-5": { "count": 4, - "min_ms": 0.27, - "p50_ms": 0.304, - "p95_ms": 0.315, - "p99_ms": 0.315, - "max_ms": 0.315 + "min_ms": 0.83, + "p50_ms": 0.866, + "p95_ms": 0.968, + "p99_ms": 0.968, + "max_ms": 0.968 }, - "/history/epbench-937920e0-5/transcript": { + "/history/epbench-a133189f-5/counts": { "count": 4, - "min_ms": 0.07, - "p50_ms": 0.076, - "p95_ms": 0.08, - "p99_ms": 0.08, - "max_ms": 0.08 + "min_ms": 0.592, + "p50_ms": 0.619, + "p95_ms": 0.656, + "p99_ms": 0.656, + "max_ms": 0.656 }, - "/files/epbench-937920e0-5": { + "/history/epbench-a133189f-5/processes": { "count": 4, - "min_ms": 2.072, - "p50_ms": 2.109, - "p95_ms": 2.298, - "p99_ms": 2.298, - "max_ms": 2.298 + "min_ms": 0.657, + "p50_ms": 0.658, + "p95_ms": 0.661, + "p99_ms": 0.661, + "max_ms": 0.661 }, - "/sessions/epbench-937920e0-5/policy-contexts": { + "/history/epbench-a133189f-5/transcript": { "count": 4, - "min_ms": 0.59, - "p50_ms": 0.61, - "p95_ms": 0.658, - "p99_ms": 0.658, - "max_ms": 0.658 + "min_ms": 0.197, + "p50_ms": 0.198, + "p95_ms": 0.21, + "p99_ms": 0.21, + "max_ms": 0.21 }, - "/info/epbench-937920e0-6": { + "/files/epbench-a133189f-5": { "count": 4, - "min_ms": 0.367, - "p50_ms": 0.382, - "p95_ms": 0.449, - "p99_ms": 0.449, - "max_ms": 0.449 + "min_ms": 2.388, + "p50_ms": 2.431, + "p95_ms": 2.518, + "p99_ms": 2.518, + "max_ms": 2.518 }, - "/logs/epbench-937920e0-6": { + "/sessions/epbench-a133189f-5/policy-contexts": { "count": 4, - "min_ms": 0.573, - "p50_ms": 0.588, - "p95_ms": 0.685, - "p99_ms": 0.685, - "max_ms": 0.685 + "min_ms": 2.243, + "p50_ms": 2.317, + "p95_ms": 2.365, + "p99_ms": 2.365, + "max_ms": 2.365 }, - "/history/epbench-937920e0-6": { + "/info/epbench-a133189f-6": { "count": 4, - "min_ms": 0.296, - "p50_ms": 0.313, - "p95_ms": 0.316, - "p99_ms": 0.316, - "max_ms": 0.316 + "min_ms": 0.868, + "p50_ms": 0.941, + "p95_ms": 1.028, + "p99_ms": 1.028, + "max_ms": 1.028 }, - "/history/epbench-937920e0-6/counts": { + "/logs/epbench-a133189f-6": { "count": 4, - "min_ms": 0.271, - "p50_ms": 0.274, - "p95_ms": 0.283, - "p99_ms": 0.283, - "max_ms": 0.283 + "min_ms": 3.024, + "p50_ms": 3.084, + "p95_ms": 3.197, + "p99_ms": 3.197, + "max_ms": 3.197 }, - "/history/epbench-937920e0-6/processes": { + "/history/epbench-a133189f-6": { "count": 4, - "min_ms": 0.276, - "p50_ms": 0.282, - "p95_ms": 0.294, - "p99_ms": 0.294, - "max_ms": 0.294 + "min_ms": 0.822, + "p50_ms": 0.828, + "p95_ms": 0.883, + "p99_ms": 0.883, + "max_ms": 0.883 }, - "/history/epbench-937920e0-6/transcript": { + "/history/epbench-a133189f-6/counts": { "count": 4, - "min_ms": 0.073, - "p50_ms": 0.077, - "p95_ms": 0.077, - "p99_ms": 0.077, - "max_ms": 0.077 + "min_ms": 0.615, + "p50_ms": 0.642, + "p95_ms": 0.761, + "p99_ms": 0.761, + "max_ms": 0.761 }, - "/files/epbench-937920e0-6": { + "/history/epbench-a133189f-6/processes": { "count": 4, - "min_ms": 2.059, - "p50_ms": 2.139, - "p95_ms": 2.239, - "p99_ms": 2.239, - "max_ms": 2.239 + "min_ms": 0.664, + "p50_ms": 0.676, + "p95_ms": 0.718, + "p99_ms": 0.718, + "max_ms": 0.718 }, - "/sessions/epbench-937920e0-6/policy-contexts": { + "/history/epbench-a133189f-6/transcript": { "count": 4, - "min_ms": 0.624, - "p50_ms": 0.629, - "p95_ms": 0.76, - "p99_ms": 0.76, - "max_ms": 0.76 + "min_ms": 0.197, + "p50_ms": 0.209, + "p95_ms": 0.217, + "p99_ms": 0.217, + "max_ms": 0.217 }, - "/info/epbench-937920e0-7": { + "/files/epbench-a133189f-6": { "count": 4, - "min_ms": 0.408, - "p50_ms": 0.411, - "p95_ms": 0.469, - "p99_ms": 0.469, - "max_ms": 0.469 + "min_ms": 2.382, + "p50_ms": 2.42, + "p95_ms": 2.587, + "p99_ms": 2.587, + "max_ms": 2.587 }, - "/logs/epbench-937920e0-7": { + "/sessions/epbench-a133189f-6/policy-contexts": { "count": 4, - "min_ms": 0.558, - "p50_ms": 0.586, - "p95_ms": 0.614, - "p99_ms": 0.614, - "max_ms": 0.614 + "min_ms": 2.298, + "p50_ms": 2.343, + "p95_ms": 2.373, + "p99_ms": 2.373, + "max_ms": 2.373 }, - "/history/epbench-937920e0-7": { + "/info/epbench-a133189f-7": { "count": 4, - "min_ms": 0.304, - "p50_ms": 0.305, - "p95_ms": 0.339, - "p99_ms": 0.339, - "max_ms": 0.339 + "min_ms": 0.867, + "p50_ms": 0.947, + "p95_ms": 0.992, + "p99_ms": 0.992, + "max_ms": 0.992 }, - "/history/epbench-937920e0-7/counts": { + "/logs/epbench-a133189f-7": { "count": 4, - "min_ms": 0.27, - "p50_ms": 0.287, - "p95_ms": 0.306, - "p99_ms": 0.306, - "max_ms": 0.306 + "min_ms": 3.115, + "p50_ms": 3.13, + "p95_ms": 3.184, + "p99_ms": 3.184, + "max_ms": 3.184 }, - "/history/epbench-937920e0-7/processes": { + "/history/epbench-a133189f-7": { "count": 4, - "min_ms": 0.262, - "p50_ms": 0.285, - "p95_ms": 0.291, - "p99_ms": 0.291, - "max_ms": 0.291 + "min_ms": 0.864, + "p50_ms": 0.87, + "p95_ms": 0.958, + "p99_ms": 0.958, + "max_ms": 0.958 }, - "/history/epbench-937920e0-7/transcript": { + "/history/epbench-a133189f-7/counts": { "count": 4, - "min_ms": 0.069, - "p50_ms": 0.07, - "p95_ms": 0.077, - "p99_ms": 0.077, - "max_ms": 0.077 + "min_ms": 0.601, + "p50_ms": 0.601, + "p95_ms": 0.635, + "p99_ms": 0.635, + "max_ms": 0.635 }, - "/files/epbench-937920e0-7": { + "/history/epbench-a133189f-7/processes": { "count": 4, - "min_ms": 2.024, - "p50_ms": 2.091, - "p95_ms": 2.254, - "p99_ms": 2.254, - "max_ms": 2.254 + "min_ms": 0.635, + "p50_ms": 0.649, + "p95_ms": 0.676, + "p99_ms": 0.676, + "max_ms": 0.676 }, - "/sessions/epbench-937920e0-7/policy-contexts": { + "/history/epbench-a133189f-7/transcript": { "count": 4, - "min_ms": 0.582, - "p50_ms": 0.622, - "p95_ms": 0.685, - "p99_ms": 0.685, - "max_ms": 0.685 + "min_ms": 0.193, + "p50_ms": 0.196, + "p95_ms": 0.218, + "p99_ms": 0.218, + "max_ms": 0.218 + }, + "/files/epbench-a133189f-7": { + "count": 4, + "min_ms": 2.365, + "p50_ms": 2.482, + "p95_ms": 2.53, + "p99_ms": 2.53, + "max_ms": 2.53 + }, + "/sessions/epbench-a133189f-7/policy-contexts": { + "count": 4, + "min_ms": 2.233, + "p50_ms": 2.3, + "p95_ms": 2.4, + "p99_ms": 2.4, + "max_ms": 2.4 } }, "gateway": { "/health": { "count": 32, - "min_ms": 0.12, - "p50_ms": 0.133, - "p95_ms": 0.187, - "p99_ms": 0.221, - "max_ms": 0.221 + "min_ms": 0.124, + "p50_ms": 0.151, + "p95_ms": 0.232, + "p99_ms": 0.259, + "max_ms": 0.259 }, "/token": { "count": 32, - "min_ms": 0.109, - "p50_ms": 0.121, - "p95_ms": 0.164, - "p99_ms": 0.166, - "max_ms": 0.166 + "min_ms": 0.117, + "p50_ms": 0.13, + "p95_ms": 0.179, + "p99_ms": 0.183, + "max_ms": 0.183 }, "/status": { "count": 32, - "min_ms": 0.186, - "p50_ms": 0.198, - "p95_ms": 0.223, - "p99_ms": 0.269, - "max_ms": 0.269 + "min_ms": 0.209, + "p50_ms": 0.227, + "p95_ms": 0.252, + "p99_ms": 0.261, + "max_ms": 0.261 } } }, "schema": "capsem.benchmark-artifact.v1", "project_version": "1.2.1780103109", "arch": "arm64", - "recorded_at": 1780103291.44931, - "recorded_at_utc": "2026-05-30T01:08:11.449313+00:00", + "recorded_at": 1780149845.194512, + "recorded_at_utc": "2026-05-30T14:04:05.194515+00:00", "command": "uv run pytest tests/capsem-serial/test_endpoint_latency_benchmark.py -xvs", "host": { "platform": "Darwin", @@ -723,29 +723,13 @@ "memory_total_gb": 128.0 }, "git": { - "commit": "a21e269c34f5eb9993f6198b635e65e00bfc42f0", + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", "dirty": true, - "source_dirty": true, + "source_dirty": false, "dirty_paths": [ - "CHANGELOG.md", - "Cargo.toml", - "crates/capsem-app/tauri.conf.json", - "crates/capsem-core/src/session/index.rs", - "crates/capsem-logger/src/reader.rs", - "crates/capsem-process/src/ipc.rs", - "crates/capsem-process/src/ipc/tests.rs", - "crates/capsem-process/src/main.rs", - "crates/capsem-service/src/main.rs", - "crates/capsem-service/src/tests.rs", - "crates/capsem-tui/src/app.rs", - "crates/capsem-tui/src/tests.rs", - "pyproject.toml", - "skills/dev-benchmark/SKILL.md", - "sprints/tui-control/MASTER.md", - "sprints/tui-control/tracker.md", - "uv.lock", - "benchmarks/endpoint-latency/data_1.2.1779673506_arm64.json", - "tests/capsem-serial/test_endpoint_latency_benchmark.py" + "benchmarks/capsem-bench/data_1.2.1780103109_arm64.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json" ] } } \ No newline at end of file diff --git a/benchmarks/fork/data_1.2.1779673506.json b/benchmarks/fork/data_1.2.1779673506.json deleted file mode 100644 index 9f537205..00000000 --- a/benchmarks/fork/data_1.2.1779673506.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "version": "0.1.0", - "timestamp": 1780076154.7209861, - "runs": 3, - "fork": { - "fork_ms": { - "min": 36.2, - "mean": 40.5, - "max": 48.0, - "values": [ - 48.0, - 37.2, - 36.2 - ] - }, - "image_size_mb": { - "min": 12.6, - "mean": 12.6, - "max": 12.6, - "values": [ - 12.61, - 12.59, - 12.62 - ] - }, - "boot_provision_ms": { - "min": 700.6, - "mean": 733.7, - "max": 750.8, - "values": [ - 700.6, - 749.8, - 750.8 - ] - }, - "boot_ready_ms": { - "min": 12.9, - "mean": 13.8, - "max": 15.0, - "values": [ - 12.9, - 15.0, - 13.6 - ] - } - } -} \ No newline at end of file diff --git a/benchmarks/fork/data_1.2.1780103109_arm64.json b/benchmarks/fork/data_1.2.1780103109_arm64.json new file mode 100644 index 00000000..74e76e5c --- /dev/null +++ b/benchmarks/fork/data_1.2.1780103109_arm64.json @@ -0,0 +1,81 @@ +{ + "version": "0.1.0", + "timestamp": 1780149863.9396212, + "runs": 3, + "fork": { + "fork_ms": { + "min": 34.0, + "mean": 35.5, + "max": 37.6, + "values": [ + 37.6, + 35.0, + 34.0 + ] + }, + "image_size_mb": { + "min": 13.1, + "mean": 13.1, + "max": 13.1, + "values": [ + 13.05, + 13.05, + 13.14 + ] + }, + "boot_provision_ms": { + "min": 746.3, + "mean": 782.1, + "max": 852.6, + "values": [ + 746.3, + 747.3, + 852.6 + ] + }, + "boot_ready_ms": { + "min": 15.1, + "mean": 15.5, + "max": 16.3, + "values": [ + 15.1, + 15.1, + 16.3 + ] + } + }, + "schema": "capsem.benchmark-artifact.v1", + "project_version": "1.2.1780103109", + "arch": "arm64", + "recorded_at": 1780149863.939942, + "recorded_at_utc": "2026-05-30T14:04:23.939945+00:00", + "command": "uv run pytest tests/capsem-serial/test_lifecycle_benchmark.py::test_fork_benchmark -xvs", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": true, + "source_dirty": false, + "dirty_paths": [ + "benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json", + "benchmarks/capsem-bench/data_1.2.1780103109_arm64.json", + "benchmarks/host-native/data_1.2.1780103109_arm64.json", + "benchmarks/lifecycle/data_1.2.1780103109_arm64.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json" + ] + } +} \ No newline at end of file diff --git a/benchmarks/host-native/data_1.2.1780103109_arm64.json b/benchmarks/host-native/data_1.2.1780103109_arm64.json new file mode 100644 index 00000000..c6315310 --- /dev/null +++ b/benchmarks/host-native/data_1.2.1780103109_arm64.json @@ -0,0 +1,164 @@ +{ + "kind": "host_native_baseline", + "version": "0.1.0", + "timestamp": 1780149845.810327, + "filesystem": { + "directory": "/Users/elie/git/capsem-tui-control/target/host-native-benchmark/tmp5i2863d0", + "disk_usage": { + "total_bytes": 3996276899840, + "used_bytes": 961723437056, + "free_bytes": 3034553462784 + } + }, + "disk": { + "directory": "/Users/elie/git/capsem-tui-control/target/host-native-benchmark/tmp5i2863d0", + "size_mb": 256, + "seq_write": { + "size_bytes": 268435456, + "block_size": 1048576, + "duration_ms": 22.5, + "throughput_mbps": 11381.3 + }, + "seq_read": { + "size_bytes": 268435456, + "block_size": 1048576, + "duration_ms": 13.2, + "throughput_mbps": 19417.4 + }, + "rand_write_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 510.4, + "iops": 19592.2, + "throughput_mbps": 76.5 + }, + "rand_read_4k": { + "count": 10000, + "block_size": 4096, + "duration_ms": 10.9, + "iops": 913471.4, + "throughput_mbps": 3568.2 + } + }, + "startup": { + "runs_per_command": 3, + "commands": { + "python3": { + "command": [ + "python3", + "--version" + ], + "timings_ms": [ + 10.9, + 10.7, + 10.7 + ], + "min_ms": 10.7, + "mean_ms": 10.8, + "max_ms": 10.9 + }, + "node": { + "command": [ + "node", + "--version" + ], + "timings_ms": [ + 21.2, + 21.3, + 26.8 + ], + "min_ms": 21.2, + "mean_ms": 23.1, + "max_ms": 26.8 + }, + "claude": { + "command": [ + "claude", + "--version" + ], + "timings_ms": [ + 2534.3, + 74.9, + 44.0 + ], + "min_ms": 44.0, + "mean_ms": 884.4, + "max_ms": 2534.3 + }, + "gemini": { + "command": [ + "gemini", + "--version" + ], + "error": "not found or timed out" + }, + "codex": { + "command": [ + "codex", + "--version" + ], + "timings_ms": [ + 20.7, + 11.2, + 20.2 + ], + "min_ms": 11.2, + "mean_ms": 17.4, + "max_ms": 20.7 + } + } + }, + "small_file_read": { + "count": 5000, + "files_sampled": 128, + "bytes_read": 3280000, + "duration_ms": 47.5, + "ops_per_sec": 105356.4, + "throughput_mbps": 65.9 + }, + "metadata_stat": { + "entries": 5050, + "files": 5000, + "dirs": 50, + "errors": 0, + "duration_ms": 14.2, + "stats_per_sec": 356587.0 + }, + "io_shape": { + "sequential_block_size": 1048576, + "random_block_size": 4096, + "size_mb": 256 + }, + "schema": "capsem.benchmark-artifact.v1", + "project_version": "1.2.1780103109", + "arch": "arm64", + "recorded_at": 1780149849.75119, + "recorded_at_utc": "2026-05-30T14:04:09.751193+00:00", + "command": "uv run pytest tests/capsem-serial/test_host_native_benchmark.py -xvs", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": true, + "source_dirty": false, + "dirty_paths": [ + "benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json", + "benchmarks/capsem-bench/data_1.2.1780103109_arm64.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json" + ] + } +} \ No newline at end of file diff --git a/benchmarks/lifecycle/data_1.2.1779673506.json b/benchmarks/lifecycle/data_1.2.1779673506.json deleted file mode 100644 index 1878a575..00000000 --- a/benchmarks/lifecycle/data_1.2.1779673506.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "version": "0.1.0", - "timestamp": 1780076144.002909, - "runs": 3, - "operations": { - "provision_ms": { - "min": 898.6, - "mean": 915.8, - "max": 948.7, - "values": [ - 900.2, - 898.6, - 948.7 - ] - }, - "exec_ready_ms": { - "min": 13.7, - "mean": 14.3, - "max": 15.5, - "values": [ - 13.7, - 13.7, - 15.5 - ] - }, - "exec_ms": { - "min": 12.2, - "mean": 13.2, - "max": 14.9, - "values": [ - 12.6, - 12.2, - 14.9 - ] - }, - "delete_ms": { - "min": 62.0, - "mean": 62.3, - "max": 62.8, - "values": [ - 62.0, - 62.2, - 62.8 - ] - }, - "total_ms": { - "min": 986.7, - "mean": 1005.7, - "max": 1041.9, - "values": [ - 988.5, - 986.7, - 1041.9 - ] - } - } -} \ No newline at end of file diff --git a/benchmarks/lifecycle/data_1.2.1780103109_arm64.json b/benchmarks/lifecycle/data_1.2.1780103109_arm64.json new file mode 100644 index 00000000..84a55377 --- /dev/null +++ b/benchmarks/lifecycle/data_1.2.1780103109_arm64.json @@ -0,0 +1,90 @@ +{ + "version": "0.1.0", + "timestamp": 1780149853.5451891, + "runs": 3, + "operations": { + "provision_ms": { + "min": 847.4, + "mean": 849.1, + "max": 851.9, + "values": [ + 851.9, + 847.4, + 847.9 + ] + }, + "exec_ready_ms": { + "min": 12.9, + "mean": 13.0, + "max": 13.1, + "values": [ + 13.1, + 12.9, + 12.9 + ] + }, + "exec_ms": { + "min": 11.8, + "mean": 12.0, + "max": 12.3, + "values": [ + 11.9, + 12.3, + 11.8 + ] + }, + "delete_ms": { + "min": 61.2, + "mean": 61.4, + "max": 61.7, + "values": [ + 61.7, + 61.3, + 61.2 + ] + }, + "total_ms": { + "min": 933.8, + "mean": 935.4, + "max": 938.6, + "values": [ + 938.6, + 933.9, + 933.8 + ] + } + }, + "schema": "capsem.benchmark-artifact.v1", + "project_version": "1.2.1780103109", + "arch": "arm64", + "recorded_at": 1780149853.5454772, + "recorded_at_utc": "2026-05-30T14:04:13.545479+00:00", + "command": "uv run pytest tests/capsem-serial/test_lifecycle_benchmark.py::test_lifecycle_benchmark -xvs", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": true, + "source_dirty": false, + "dirty_paths": [ + "benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json", + "benchmarks/capsem-bench/data_1.2.1780103109_arm64.json", + "benchmarks/host-native/data_1.2.1780103109_arm64.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json" + ] + } +} \ No newline at end of file diff --git a/benchmarks/parallel/data_1.2.1780103109_arm64.json b/benchmarks/parallel/data_1.2.1780103109_arm64.json new file mode 100644 index 00000000..9ee77ff1 --- /dev/null +++ b/benchmarks/parallel/data_1.2.1780103109_arm64.json @@ -0,0 +1,67 @@ +{ + "version": "1.0", + "timestamp": 1780149901.62083, + "num_vms": 4, + "total_duration_ms": 33184.21941692941, + "results": [ + { + "vm": "par-bench-a578d8-0", + "status": "success", + "duration_ms": 33183.09783306904, + "stdout": " Scratch Disk I/O [/root, 256 MB] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Test \u2503 Throughput \u2503 IOPS \u2503 Duration \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Seq write (1MB) \u2502 813.6 MB/s \u2502 - \u2502 314.6 ms \u2502\n\u2502 Seq read (1MB) \u2502 1817.6 MB/s \u2502 - \u2502 140.8 ms \u2502\n\u2502 Rand write (4K) \u2502 22.2 MB/s \u2502 5679 \u2502 1760.9 ms \u2502\n\u2502 Rand read (4K) \u2502 126.5 MB/s \u2502 32373 \u2502 308.9 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Rootfs Read I/O \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Test \u2503 Detail \u2503 Throughput \u2503 IOPS \u2503 Duration \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Seq read (1MB) \u2502 claude.exe (227.4 MB) \u2502 652.6 MB/s \u2502 - \u2502 348.4 ms \u2502\n\u2502 Rand read (4K) \u2502 2575 files \u2502 17.1 MB/s \u2502 4376 \u2502 1142.5 ms \u2502\n\u2502 Large bin cold \u2502 3 files \u2502 815.0 MB/s \u2502 - \u2502 777.9 ms \u2502\n\u2502 Large bin warm \u2502 3 files \u2502 24014.1 MB/s \u2502 - \u2502 26.4 ms \u2502\n\u2502 Small JS reads \u2502 113 files \u2502 2299.2 MB/s \u2502 276803 \u2502 18.1 ms \u2502\n\u2502 Metadata stat \u2502 6571 entries \u2502 - \u2502 152768 \u2502 43.0 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Storage Path Diagnostics \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 \u2503 \u2503 \u2503 Cold \u2503 \u2503 Rand \u2503 Rand \u2503\n\u2503 Path \u2503 FS \u2503 Write \u2503 Read \u2503 Warm Read \u2503 Read \u2503 Write \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 /root \u2502 virtiofs \u2502 1632.7 \u2502 2935.7 \u2502 3423.6 \u2502 35930 \u2502 5449 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /tmp \u2502 overlay \u2502 7442.4 \u2502 8349.2 \u2502 22022.1 \u2502 1411034 \u2502 4973 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /var/tmp \u2502 overlay \u2502 7640.5 \u2502 8692.6 \u2502 21253.6 \u2502 1658868 \u2502 4968 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /var/log \u2502 overlay \u2502 7883.2 \u2502 10002.3 \u2502 22782.2 \u2502 1693779 \u2502 5287 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /run \u2502 overlay \u2502 8060.3 \u2502 10042.2 \u2502 24190.9 \u2502 778862 \u2502 5054 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 862.8 \u2502 19436.8 \u2502 - \u2502 - \u2502\n\u2502 (227.4 \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 MB) \u2502 \u2502 \u2502 \u2502 \u2502 \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 2084.3 \u2502 20795.2 \u2502 - \u2502 - \u2502\n\u2502 (1.3 MB) \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 1276.1 \u2502 24474.6 \u2502 - \u2502 - \u2502\n\u2502 (6.3 MB) \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Storage I/O Profile \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Path \u2503 Workload \u2503 Block \u2503 IOPS \u2503 Throughput \u2503 Avg Lat \u2503 P95 Lat \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 /root \u2502 seq_write \u2502 4k \u2502 11083 \u2502 43.3 MB/s \u2502 0.09 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 4k \u2502 674750 \u2502 2635.7 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 4k \u2502 695436 \u2502 2716.5 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_write \u2502 64k \u2502 7910 \u2502 494.4 MB/s \u2502 0.126 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 64k \u2502 48212 \u2502 3013.3 MB/s \u2502 0.021 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 64k \u2502 47124 \u2502 2945.3 MB/s \u2502 0.021 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_write \u2502 1m \u2502 1647 \u2502 1647.0 MB/s \u2502 0.607 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 1m \u2502 3531 \u2502 3530.6 MB/s \u2502 0.283 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 1m \u2502 3540 \u2502 3539.5 MB/s \u2502 0.283 ms \u2502 - \u2502\n\u2502 /root \u2502 read_4k \u2502 4k \u2502 27814 \u2502 108.6 MB/s \u2502 0.036 ms \u2502 0.055 ms \u2502\n\u2502 /root \u2502 write_4k_s\u2026 \u2502 4k \u2502 6547 \u2502 25.6 MB/s \u2502 0.153 ms \u2502 0.213 ms \u2502\n\u2502 /tmp \u2502 seq_write \u2502 4k \u2502 1305693 \u2502 5100.4 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 4k \u2502 1603863 \u2502 6265.1 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 4k \u2502 2231595 \u2502 8717.2 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_write \u2502 64k \u2502 120578 \u2502 7536.1 MB/s \u2502 0.008 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 64k \u2502 153563 \u2502 9597.7 MB/s \u2502 0.007 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 64k \u2502 328231 \u2502 20514.5 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /tmp \u2502 seq_write \u2502 1m \u2502 7905 \u2502 7905.0 MB/s \u2502 0.127 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 1m \u2502 9374 \u2502 9374.0 MB/s \u2502 0.107 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 1m \u2502 21561 \u2502 21560.9 \u2502 0.046 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /tmp \u2502 read_4k \u2502 4k \u2502 20698 \u2502 80.8 MB/s \u2502 0.048 ms \u2502 0.076 ms \u2502\n\u2502 /tmp \u2502 write_4k_s\u2026 \u2502 4k \u2502 9391 \u2502 36.7 MB/s \u2502 0.106 ms \u2502 0.156 ms \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 4k \u2502 1477856 \u2502 5772.9 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 4k \u2502 1831663 \u2502 7154.9 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 4k \u2502 2394155 \u2502 9352.2 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 64k \u2502 132435 \u2502 8277.2 MB/s \u2502 0.008 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 64k \u2502 173188 \u2502 10824.2 \u2502 0.006 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 64k \u2502 350875 \u2502 21929.7 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 1m \u2502 8268 \u2502 8268.2 MB/s \u2502 0.121 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 1m \u2502 11128 \u2502 11128.2 \u2502 0.09 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 1m \u2502 23323 \u2502 23322.6 \u2502 0.043 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 read_4k \u2502 4k \u2502 24638 \u2502 96.2 MB/s \u2502 0.041 ms \u2502 0.064 ms \u2502\n\u2502 /var/tmp \u2502 write_4k_s\u2026 \u2502 4k \u2502 11264 \u2502 44.0 MB/s \u2502 0.089 ms \u2502 0.13 ms \u2502\n\u2502 /var/log \u2502 seq_write \u2502 4k \u2502 1501690 \u2502 5866.0 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 4k \u2502 1956036 \u2502 7640.8 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 4k \u2502 2654766 \u2502 10370.2 \u2502 0.0 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_write \u2502 64k \u2502 131294 \u2502 8205.9 MB/s \u2502 0.008 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 64k \u2502 178541 \u2502 11158.8 \u2502 0.006 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 64k \u2502 339288 \u2502 21205.5 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_write \u2502 1m \u2502 8318 \u2502 8317.7 MB/s \u2502 0.12 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 1m \u2502 10478 \u2502 10477.5 \u2502 0.095 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 1m \u2502 24097 \u2502 24097.1 \u2502 0.041 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 read_4k \u2502 4k \u2502 24990 \u2502 97.6 MB/s \u2502 0.04 ms \u2502 0.062 ms \u2502\n\u2502 /var/log \u2502 write_4k_s\u2026 \u2502 4k \u2502 10182 \u2502 39.8 MB/s \u2502 0.098 ms \u2502 0.148 ms \u2502\n\u2502 /run \u2502 seq_write \u2502 4k \u2502 926730 \u2502 3620.0 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 4k \u2502 1591723 \u2502 6217.7 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 4k \u2502 2368828 \u2502 9253.2 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_write \u2502 64k \u2502 96914 \u2502 6057.1 MB/s \u2502 0.01 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 64k \u2502 116551 \u2502 7284.4 MB/s \u2502 0.009 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 64k \u2502 298687 \u2502 18668.0 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /run \u2502 seq_write \u2502 1m \u2502 7192 \u2502 7192.3 MB/s \u2502 0.139 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 1m \u2502 7227 \u2502 7227.0 MB/s \u2502 0.138 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 1m \u2502 16157 \u2502 16157.0 \u2502 0.062 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /run \u2502 read_4k \u2502 4k \u2502 17437 \u2502 68.1 MB/s \u2502 0.057 ms \u2502 0.131 ms \u2502\n\u2502 /run \u2502 write_4k_s\u2026 \u2502 4k \u2502 12536 \u2502 49.0 MB/s \u2502 0.08 ms \u2502 0.14 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n CLI Cold Start Latency [3 runs each] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Command \u2503 Min (ms) \u2503 Mean (ms) \u2503 Max (ms) \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 python3 \u2502 7.3 \u2502 8.3 \u2502 9.2 \u2502\n\u2502 node \u2502 79.6 \u2502 81.4 \u2502 82.6 \u2502\n\u2502 claude \u2502 335.2 \u2502 338.0 \u2502 343.0 \u2502\n\u2502 gemini \u2502 865.7 \u2502 914.7 \u2502 972.7 \u2502\n\u2502 codex \u2502 233.2 \u2502 235.9 \u2502 237.4 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n HTTP Benchmark \n [https://www.google.com/] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Metric \u2503 Value \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Requests \u2502 50/50 \u2502\n\u2502 Concurrency \u2502 5 \u2502\n\u2502 Requests/sec \u2502 28.3 \u2502\n\u2502 Transfer \u2502 3.8 MB \u2502\n\u2502 Duration \u2502 1765.6 ms \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Latency min \u2502 53.5 ms \u2502\n\u2502 Latency mean \u2502 156.3 ms \u2502\n\u2502 Latency p50 \u2502 60.7 ms \u2502\n\u2502 Latency p95 \u2502 918.9 ms \u2502\n\u2502 Latency p99 \u2502 1162.9 ms \u2502\n\u2502 Latency max \u2502 1199.7 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Proxy Throughput \n [https://cdn.elie.net/static/files/i-am-a-legend/i-am-a-legend-slides.pdf] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Metric \u2503 Value \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 URL \u2502 https://cdn.elie.net/static/files/i-am-a-legend/i-am-a-legend-\u2026 \u2502\n\u2502 Downloaded \u2502 9.5 MB \u2502\n\u2502 Duration \u2502 0.45s \u2502\n\u2502 Throughput \u2502 21.16 MB/s \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Snapshot Operations (e2e via MCP) \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Operation \u2503 Files \u2503 Latency (ms) \u2503 Status \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 create \u2502 10 files \u2502 871.5 \u2502 ok \u2502\n\u2502 list \u2502 10 files \u2502 368.7 \u2502 ok \u2502\n\u2502 changes \u2502 10 files \u2502 341.0 \u2502 ok \u2502\n\u2502 revert \u2502 10 files \u2502 332.1 \u2502 ok \u2502\n\u2502 delete \u2502 10 files \u2502 348.5 \u2502 ok \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 create \u2502 100 files \u2502 331.4 \u2502 ok \u2502\n\u2502 list \u2502 100 files \u2502 350.0 \u2502 ok \u2502\n\u2502 changes \u2502 100 files \u2502 337.7 \u2502 ok \u2502\n\u2502 revert \u2502 100 files \u2502 322.6 \u2502 ok \u2502\n\u2502 delete \u2502 100 files \u2502 293.3 \u2502 ok \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 create \u2502 500 files \u2502 288.0 \u2502 ok \u2502\n\u2502 list \u2502 500 files \u2502 281.4 \u2502 ok \u2502\n\u2502 changes \u2502 500 files \u2502 298.9 \u2502 ok \u2502\n\u2502 revert \u2502 500 files \u2502 288.9 \u2502 ok \u2502\n\u2502 delete \u2502 500 files \u2502 289.3 \u2502 ok \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\nJSON results saved to /tmp/capsem-benchmark.json\n" + }, + { + "vm": "par-bench-17e28d-1", + "status": "success", + "duration_ms": 30974.315082887188, + "stdout": " Scratch Disk I/O [/root, 256 MB] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Test \u2503 Throughput \u2503 IOPS \u2503 Duration \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Seq write (1MB) \u2502 975.0 MB/s \u2502 - \u2502 262.6 ms \u2502\n\u2502 Seq read (1MB) \u2502 1959.8 MB/s \u2502 - \u2502 130.6 ms \u2502\n\u2502 Rand write (4K) \u2502 24.7 MB/s \u2502 6322 \u2502 1581.7 ms \u2502\n\u2502 Rand read (4K) \u2502 195.7 MB/s \u2502 50097 \u2502 199.6 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Rootfs Read I/O \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Test \u2503 Detail \u2503 Throughput \u2503 IOPS \u2503 Duration \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Seq read (1MB) \u2502 claude.exe (227.4 MB) \u2502 791.6 MB/s \u2502 - \u2502 287.2 ms \u2502\n\u2502 Rand read (4K) \u2502 2592 files \u2502 21.1 MB/s \u2502 5392 \u2502 927.4 ms \u2502\n\u2502 Large bin cold \u2502 3 files \u2502 744.3 MB/s \u2502 - \u2502 851.8 ms \u2502\n\u2502 Large bin warm \u2502 3 files \u2502 21637.3 MB/s \u2502 - \u2502 29.3 ms \u2502\n\u2502 Small JS reads \u2502 113 files \u2502 2729.4 MB/s \u2502 316232 \u2502 15.8 ms \u2502\n\u2502 Metadata stat \u2502 6571 entries \u2502 - \u2502 156951 \u2502 41.9 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Storage Path Diagnostics \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 \u2503 \u2503 \u2503 Cold \u2503 \u2503 Rand \u2503 Rand \u2503\n\u2503 Path \u2503 FS \u2503 Write \u2503 Read \u2503 Warm Read \u2503 Read \u2503 Write \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 /root \u2502 virtiofs \u2502 2022.8 \u2502 4000.6 \u2502 4558.6 \u2502 60426 \u2502 7472 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /tmp \u2502 overlay \u2502 6986.3 \u2502 9928.1 \u2502 20361.1 \u2502 1277160 \u2502 4592 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /var/tmp \u2502 overlay \u2502 7055.5 \u2502 7969.0 \u2502 17371.0 \u2502 1681862 \u2502 4711 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /var/log \u2502 overlay \u2502 7548.1 \u2502 8859.4 \u2502 19077.7 \u2502 1608202 \u2502 5618 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /run \u2502 overlay \u2502 8079.4 \u2502 9909.2 \u2502 20061.6 \u2502 1694568 \u2502 5565 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 941.6 \u2502 22290.6 \u2502 - \u2502 - \u2502\n\u2502 (227.4 \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 MB) \u2502 \u2502 \u2502 \u2502 \u2502 \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 2250.8 \u2502 19468.5 \u2502 - \u2502 - \u2502\n\u2502 (1.3 MB) \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 1349.9 \u2502 25552.3 \u2502 - \u2502 - \u2502\n\u2502 (6.3 MB) \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Storage I/O Profile \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Path \u2503 Workload \u2503 Block \u2503 IOPS \u2503 Throughput \u2503 Avg Lat \u2503 P95 Lat \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 /root \u2502 seq_write \u2502 4k \u2502 15664 \u2502 61.2 MB/s \u2502 0.064 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 4k \u2502 1024945 \u2502 4003.7 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 4k \u2502 1010197 \u2502 3946.1 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_write \u2502 64k \u2502 13340 \u2502 833.8 MB/s \u2502 0.075 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 64k \u2502 65533 \u2502 4095.8 MB/s \u2502 0.015 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 64k \u2502 64796 \u2502 4049.8 MB/s \u2502 0.015 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_write \u2502 1m \u2502 2273 \u2502 2272.8 MB/s \u2502 0.44 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 1m \u2502 4525 \u2502 4525.0 MB/s \u2502 0.221 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 1m \u2502 4232 \u2502 4232.0 MB/s \u2502 0.236 ms \u2502 - \u2502\n\u2502 /root \u2502 read_4k \u2502 4k \u2502 38109 \u2502 148.9 MB/s \u2502 0.026 ms \u2502 0.046 ms \u2502\n\u2502 /root \u2502 write_4k_s\u2026 \u2502 4k \u2502 8771 \u2502 34.3 MB/s \u2502 0.114 ms \u2502 0.15 ms \u2502\n\u2502 /tmp \u2502 seq_write \u2502 4k \u2502 1083956 \u2502 4234.2 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 4k \u2502 1780096 \u2502 6953.5 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 4k \u2502 2413478 \u2502 9427.7 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_write \u2502 64k \u2502 119846 \u2502 7490.4 MB/s \u2502 0.008 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 64k \u2502 158043 \u2502 9877.7 MB/s \u2502 0.006 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 64k \u2502 280650 \u2502 17540.7 \u2502 0.004 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /tmp \u2502 seq_write \u2502 1m \u2502 6862 \u2502 6862.0 MB/s \u2502 0.146 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 1m \u2502 9436 \u2502 9436.1 MB/s \u2502 0.106 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 1m \u2502 17820 \u2502 17819.9 \u2502 0.056 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /tmp \u2502 read_4k \u2502 4k \u2502 33306 \u2502 130.1 MB/s \u2502 0.03 ms \u2502 0.049 ms \u2502\n\u2502 /tmp \u2502 write_4k_s\u2026 \u2502 4k \u2502 14039 \u2502 54.8 MB/s \u2502 0.071 ms \u2502 0.106 ms \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 4k \u2502 1436007 \u2502 5609.4 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 4k \u2502 1812022 \u2502 7078.2 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 4k \u2502 2330376 \u2502 9103.0 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 64k \u2502 111092 \u2502 6943.3 MB/s \u2502 0.009 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 64k \u2502 170745 \u2502 10671.6 \u2502 0.006 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 64k \u2502 302332 \u2502 18895.8 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 1m \u2502 7454 \u2502 7454.3 MB/s \u2502 0.134 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 1m \u2502 9792 \u2502 9792.5 MB/s \u2502 0.102 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 1m \u2502 19100 \u2502 19100.0 \u2502 0.052 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 read_4k \u2502 4k \u2502 34086 \u2502 133.1 MB/s \u2502 0.029 ms \u2502 0.047 ms \u2502\n\u2502 /var/tmp \u2502 write_4k_s\u2026 \u2502 4k \u2502 15517 \u2502 60.6 MB/s \u2502 0.064 ms \u2502 0.097 ms \u2502\n\u2502 /var/log \u2502 seq_write \u2502 4k \u2502 1478039 \u2502 5773.6 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 4k \u2502 1674264 \u2502 6540.1 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 4k \u2502 2306712 \u2502 9010.6 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_write \u2502 64k \u2502 124310 \u2502 7769.3 MB/s \u2502 0.008 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 64k \u2502 163628 \u2502 10226.8 \u2502 0.006 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 64k \u2502 297548 \u2502 18596.8 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_write \u2502 1m \u2502 8006 \u2502 8006.5 MB/s \u2502 0.125 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 1m \u2502 10216 \u2502 10216.5 \u2502 0.098 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 1m \u2502 18565 \u2502 18565.3 \u2502 0.054 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 read_4k \u2502 4k \u2502 25339 \u2502 99.0 MB/s \u2502 0.039 ms \u2502 0.06 ms \u2502\n\u2502 /var/log \u2502 write_4k_s\u2026 \u2502 4k \u2502 11555 \u2502 45.1 MB/s \u2502 0.087 ms \u2502 0.125 ms \u2502\n\u2502 /run \u2502 seq_write \u2502 4k \u2502 1511050 \u2502 5902.5 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 4k \u2502 1867974 \u2502 7296.8 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 4k \u2502 2516115 \u2502 9828.6 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_write \u2502 64k \u2502 126266 \u2502 7891.6 MB/s \u2502 0.008 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 64k \u2502 153817 \u2502 9613.6 MB/s \u2502 0.007 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 64k \u2502 290820 \u2502 18176.2 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /run \u2502 seq_write \u2502 1m \u2502 7514 \u2502 7514.5 MB/s \u2502 0.133 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 1m \u2502 9149 \u2502 9148.7 MB/s \u2502 0.109 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 1m \u2502 19866 \u2502 19866.0 \u2502 0.05 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /run \u2502 read_4k \u2502 4k \u2502 22228 \u2502 86.8 MB/s \u2502 0.045 ms \u2502 0.071 ms \u2502\n\u2502 /run \u2502 write_4k_s\u2026 \u2502 4k \u2502 10858 \u2502 42.4 MB/s \u2502 0.092 ms \u2502 0.134 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n CLI Cold Start Latency [3 runs each] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Command \u2503 Min (ms) \u2503 Mean (ms) \u2503 Max (ms) \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 python3 \u2502 5.4 \u2502 7.0 \u2502 8.5 \u2502\n\u2502 node \u2502 128.0 \u2502 129.7 \u2502 130.7 \u2502\n\u2502 claude \u2502 340.2 \u2502 342.2 \u2502 343.9 \u2502\n\u2502 gemini \u2502 925.8 \u2502 971.1 \u2502 1018.4 \u2502\n\u2502 codex \u2502 241.3 \u2502 274.4 \u2502 292.8 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n HTTP Benchmark \n [https://www.google.com/] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Metric \u2503 Value \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Requests \u2502 50/50 \u2502\n\u2502 Concurrency \u2502 5 \u2502\n\u2502 Requests/sec \u2502 66.4 \u2502\n\u2502 Transfer \u2502 3.8 MB \u2502\n\u2502 Duration \u2502 753.5 ms \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Latency min \u2502 51.5 ms \u2502\n\u2502 Latency mean \u2502 72.8 ms \u2502\n\u2502 Latency p50 \u2502 59.9 ms \u2502\n\u2502 Latency p95 \u2502 176.3 ms \u2502\n\u2502 Latency p99 \u2502 185.0 ms \u2502\n\u2502 Latency max \u2502 189.6 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Proxy Throughput \n [https://cdn.elie.net/static/files/i-am-a-legend/i-am-a-legend-slides.pdf] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Metric \u2503 Value \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 URL \u2502 https://cdn.elie.net/static/files/i-am-a-legend/i-am-a-legend-\u2026 \u2502\n\u2502 Downloaded \u2502 9.5 MB \u2502\n\u2502 Duration \u2502 0.52s \u2502\n\u2502 Throughput \u2502 18.31 MB/s \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Snapshot Operations (e2e via MCP) \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Operation \u2503 Files \u2503 Latency (ms) \u2503 Status \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 create \u2502 10 files \u2502 812.1 \u2502 ok \u2502\n\u2502 list \u2502 10 files \u2502 371.2 \u2502 ok \u2502\n\u2502 changes \u2502 10 files \u2502 394.7 \u2502 ok \u2502\n\u2502 revert \u2502 10 files \u2502 396.5 \u2502 ok \u2502\n\u2502 delete \u2502 10 files \u2502 358.3 \u2502 ok \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 create \u2502 100 files \u2502 355.8 \u2502 ok \u2502\n\u2502 list \u2502 100 files \u2502 335.5 \u2502 ok \u2502\n\u2502 changes \u2502 100 files \u2502 339.4 \u2502 ok \u2502\n\u2502 revert \u2502 100 files \u2502 359.8 \u2502 ok \u2502\n\u2502 delete \u2502 100 files \u2502 354.9 \u2502 ok \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 create \u2502 500 files \u2502 339.1 \u2502 ok \u2502\n\u2502 list \u2502 500 files \u2502 345.9 \u2502 ok \u2502\n\u2502 changes \u2502 500 files \u2502 363.4 \u2502 ok \u2502\n\u2502 revert \u2502 500 files \u2502 351.3 \u2502 ok \u2502\n\u2502 delete \u2502 500 files \u2502 346.0 \u2502 ok \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\nJSON results saved to /tmp/capsem-benchmark.json\n" + }, + { + "vm": "par-bench-21c4d7-2", + "status": "success", + "duration_ms": 31158.650916069746, + "stdout": " Scratch Disk I/O [/root, 256 MB] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Test \u2503 Throughput \u2503 IOPS \u2503 Duration \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Seq write (1MB) \u2502 975.2 MB/s \u2502 - \u2502 262.5 ms \u2502\n\u2502 Seq read (1MB) \u2502 1969.6 MB/s \u2502 - \u2502 130.0 ms \u2502\n\u2502 Rand write (4K) \u2502 25.4 MB/s \u2502 6493 \u2502 1540.1 ms \u2502\n\u2502 Rand read (4K) \u2502 194.6 MB/s \u2502 49814 \u2502 200.7 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Rootfs Read I/O \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Test \u2503 Detail \u2503 Throughput \u2503 IOPS \u2503 Duration \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Seq read (1MB) \u2502 claude.exe (227.4 MB) \u2502 799.9 MB/s \u2502 - \u2502 284.2 ms \u2502\n\u2502 Rand read (4K) \u2502 2568 files \u2502 21.5 MB/s \u2502 5508 \u2502 907.7 ms \u2502\n\u2502 Large bin cold \u2502 3 files \u2502 748.6 MB/s \u2502 - \u2502 846.9 ms \u2502\n\u2502 Large bin warm \u2502 3 files \u2502 19999.1 MB/s \u2502 - \u2502 31.7 ms \u2502\n\u2502 Small JS reads \u2502 113 files \u2502 2900.5 MB/s \u2502 327330 \u2502 15.3 ms \u2502\n\u2502 Metadata stat \u2502 6571 entries \u2502 - \u2502 162362 \u2502 40.5 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Storage Path Diagnostics \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 \u2503 \u2503 \u2503 Cold \u2503 \u2503 Rand \u2503 Rand \u2503\n\u2503 Path \u2503 FS \u2503 Write \u2503 Read \u2503 Warm Read \u2503 Read \u2503 Write \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 /root \u2502 virtiofs \u2502 2083.2 \u2502 3822.1 \u2502 4428.0 \u2502 55322 \u2502 6961 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /tmp \u2502 overlay \u2502 7796.9 \u2502 10239.3 \u2502 21317.9 \u2502 1451247 \u2502 4977 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /var/tmp \u2502 overlay \u2502 7999.2 \u2502 8988.1 \u2502 17511.3 \u2502 1524836 \u2502 4751 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /var/log \u2502 overlay \u2502 7569.6 \u2502 8217.2 \u2502 16622.7 \u2502 1592336 \u2502 4859 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /run \u2502 overlay \u2502 7782.2 \u2502 9525.1 \u2502 18867.7 \u2502 1571123 \u2502 5065 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 938.5 \u2502 23837.4 \u2502 - \u2502 - \u2502\n\u2502 (227.4 \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 MB) \u2502 \u2502 \u2502 \u2502 \u2502 \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 1904.3 \u2502 18246.6 \u2502 - \u2502 - \u2502\n\u2502 (1.3 MB) \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 1405.3 \u2502 20468.8 \u2502 - \u2502 - \u2502\n\u2502 (6.3 MB) \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Storage I/O Profile \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Path \u2503 Workload \u2503 Block \u2503 IOPS \u2503 Throughput \u2503 Avg Lat \u2503 P95 Lat \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 /root \u2502 seq_write \u2502 4k \u2502 15635 \u2502 61.1 MB/s \u2502 0.064 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 4k \u2502 986921 \u2502 3855.2 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 4k \u2502 989200 \u2502 3864.1 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_write \u2502 64k \u2502 13393 \u2502 837.0 MB/s \u2502 0.075 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 64k \u2502 59832 \u2502 3739.5 MB/s \u2502 0.017 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 64k \u2502 62197 \u2502 3887.3 MB/s \u2502 0.016 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_write \u2502 1m \u2502 2327 \u2502 2326.9 MB/s \u2502 0.43 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 1m \u2502 4569 \u2502 4568.6 MB/s \u2502 0.219 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 1m \u2502 4542 \u2502 4541.5 MB/s \u2502 0.22 ms \u2502 - \u2502\n\u2502 /root \u2502 read_4k \u2502 4k \u2502 42685 \u2502 166.7 MB/s \u2502 0.023 ms \u2502 0.043 ms \u2502\n\u2502 /root \u2502 write_4k_s\u2026 \u2502 4k \u2502 9202 \u2502 35.9 MB/s \u2502 0.109 ms \u2502 0.141 ms \u2502\n\u2502 /tmp \u2502 seq_write \u2502 4k \u2502 1129288 \u2502 4411.3 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 4k \u2502 1891999 \u2502 7390.6 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 4k \u2502 2502711 \u2502 9776.2 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_write \u2502 64k \u2502 123887 \u2502 7742.9 MB/s \u2502 0.008 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 64k \u2502 169463 \u2502 10591.4 \u2502 0.006 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 64k \u2502 291925 \u2502 18245.3 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /tmp \u2502 seq_write \u2502 1m \u2502 7866 \u2502 7865.6 MB/s \u2502 0.127 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 1m \u2502 9854 \u2502 9853.6 MB/s \u2502 0.101 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 1m \u2502 17552 \u2502 17552.3 \u2502 0.057 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /tmp \u2502 read_4k \u2502 4k \u2502 42010 \u2502 164.1 MB/s \u2502 0.024 ms \u2502 0.048 ms \u2502\n\u2502 /tmp \u2502 write_4k_s\u2026 \u2502 4k \u2502 15842 \u2502 61.9 MB/s \u2502 0.063 ms \u2502 0.093 ms \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 4k \u2502 1464372 \u2502 5720.2 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 4k \u2502 1885304 \u2502 7364.5 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 4k \u2502 2448952 \u2502 9566.2 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 64k \u2502 126133 \u2502 7883.3 MB/s \u2502 0.008 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 64k \u2502 170807 \u2502 10675.4 \u2502 0.006 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 64k \u2502 283493 \u2502 17718.3 \u2502 0.004 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 1m \u2502 8008 \u2502 8007.9 MB/s \u2502 0.125 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 1m \u2502 10199 \u2502 10198.7 \u2502 0.098 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 1m \u2502 20588 \u2502 20588.4 \u2502 0.049 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 read_4k \u2502 4k \u2502 34698 \u2502 135.5 MB/s \u2502 0.029 ms \u2502 0.046 ms \u2502\n\u2502 /var/tmp \u2502 write_4k_s\u2026 \u2502 4k \u2502 15492 \u2502 60.5 MB/s \u2502 0.065 ms \u2502 0.101 ms \u2502\n\u2502 /var/log \u2502 seq_write \u2502 4k \u2502 1470434 \u2502 5743.9 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 4k \u2502 1857351 \u2502 7255.3 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 4k \u2502 2475220 \u2502 9668.8 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_write \u2502 64k \u2502 121994 \u2502 7624.6 MB/s \u2502 0.008 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 64k \u2502 176901 \u2502 11056.3 \u2502 0.006 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 64k \u2502 287385 \u2502 17961.5 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_write \u2502 1m \u2502 8037 \u2502 8037.2 MB/s \u2502 0.124 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 1m \u2502 9880 \u2502 9879.9 MB/s \u2502 0.101 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 1m \u2502 19987 \u2502 19986.7 \u2502 0.05 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 read_4k \u2502 4k \u2502 33716 \u2502 131.7 MB/s \u2502 0.03 ms \u2502 0.046 ms \u2502\n\u2502 /var/log \u2502 write_4k_s\u2026 \u2502 4k \u2502 15407 \u2502 60.2 MB/s \u2502 0.065 ms \u2502 0.093 ms \u2502\n\u2502 /run \u2502 seq_write \u2502 4k \u2502 1500320 \u2502 5860.6 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 4k \u2502 1794876 \u2502 7011.2 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 4k \u2502 2311553 \u2502 9029.5 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_write \u2502 64k \u2502 120309 \u2502 7519.3 MB/s \u2502 0.008 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 64k \u2502 177858 \u2502 11116.1 \u2502 0.006 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 64k \u2502 311566 \u2502 19472.9 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /run \u2502 seq_write \u2502 1m \u2502 7822 \u2502 7822.5 MB/s \u2502 0.128 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 1m \u2502 9849 \u2502 9849.2 MB/s \u2502 0.102 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 1m \u2502 19717 \u2502 19716.8 \u2502 0.051 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /run \u2502 read_4k \u2502 4k \u2502 34581 \u2502 135.1 MB/s \u2502 0.029 ms \u2502 0.047 ms \u2502\n\u2502 /run \u2502 write_4k_s\u2026 \u2502 4k \u2502 15569 \u2502 60.8 MB/s \u2502 0.064 ms \u2502 0.092 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n CLI Cold Start Latency [3 runs each] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Command \u2503 Min (ms) \u2503 Mean (ms) \u2503 Max (ms) \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 python3 \u2502 7.5 \u2502 8.3 \u2502 9.7 \u2502\n\u2502 node \u2502 127.6 \u2502 129.9 \u2502 131.5 \u2502\n\u2502 claude \u2502 338.4 \u2502 339.5 \u2502 340.7 \u2502\n\u2502 gemini \u2502 917.4 \u2502 951.1 \u2502 1013.9 \u2502\n\u2502 codex \u2502 240.5 \u2502 258.2 \u2502 293.5 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n HTTP Benchmark \n [https://www.google.com/] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Metric \u2503 Value \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Requests \u2502 50/50 \u2502\n\u2502 Concurrency \u2502 5 \u2502\n\u2502 Requests/sec \u2502 65.2 \u2502\n\u2502 Transfer \u2502 3.8 MB \u2502\n\u2502 Duration \u2502 767.4 ms \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Latency min \u2502 52.8 ms \u2502\n\u2502 Latency mean \u2502 75.5 ms \u2502\n\u2502 Latency p50 \u2502 60.3 ms \u2502\n\u2502 Latency p95 \u2502 195.2 ms \u2502\n\u2502 Latency p99 \u2502 203.1 ms \u2502\n\u2502 Latency max \u2502 203.8 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Proxy Throughput \n [https://cdn.elie.net/static/files/i-am-a-legend/i-am-a-legend-slides.pdf] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Metric \u2503 Value \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 URL \u2502 https://cdn.elie.net/static/files/i-am-a-legend/i-am-a-legend-\u2026 \u2502\n\u2502 Downloaded \u2502 9.5 MB \u2502\n\u2502 Duration \u2502 0.62s \u2502\n\u2502 Throughput \u2502 15.29 MB/s \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Snapshot Operations (e2e via MCP) \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Operation \u2503 Files \u2503 Latency (ms) \u2503 Status \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 create \u2502 10 files \u2502 822.3 \u2502 ok \u2502\n\u2502 list \u2502 10 files \u2502 420.4 \u2502 ok \u2502\n\u2502 changes \u2502 10 files \u2502 392.7 \u2502 ok \u2502\n\u2502 revert \u2502 10 files \u2502 376.0 \u2502 ok \u2502\n\u2502 delete \u2502 10 files \u2502 360.1 \u2502 ok \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 create \u2502 100 files \u2502 338.4 \u2502 ok \u2502\n\u2502 list \u2502 100 files \u2502 337.4 \u2502 ok \u2502\n\u2502 changes \u2502 100 files \u2502 340.0 \u2502 ok \u2502\n\u2502 revert \u2502 100 files \u2502 370.3 \u2502 ok \u2502\n\u2502 delete \u2502 100 files \u2502 333.8 \u2502 ok \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 create \u2502 500 files \u2502 354.8 \u2502 ok \u2502\n\u2502 list \u2502 500 files \u2502 337.2 \u2502 ok \u2502\n\u2502 changes \u2502 500 files \u2502 374.4 \u2502 ok \u2502\n\u2502 revert \u2502 500 files \u2502 348.8 \u2502 ok \u2502\n\u2502 delete \u2502 500 files \u2502 331.5 \u2502 ok \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\nJSON results saved to /tmp/capsem-benchmark.json\n" + }, + { + "vm": "par-bench-97976a-3", + "status": "success", + "duration_ms": 31644.89100011997, + "stdout": " Scratch Disk I/O [/root, 256 MB] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Test \u2503 Throughput \u2503 IOPS \u2503 Duration \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Seq write (1MB) \u2502 966.6 MB/s \u2502 - \u2502 264.8 ms \u2502\n\u2502 Seq read (1MB) \u2502 1922.0 MB/s \u2502 - \u2502 133.2 ms \u2502\n\u2502 Rand write (4K) \u2502 23.2 MB/s \u2502 5931 \u2502 1686.0 ms \u2502\n\u2502 Rand read (4K) \u2502 202.4 MB/s \u2502 51823 \u2502 193.0 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Rootfs Read I/O \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Test \u2503 Detail \u2503 Throughput \u2503 IOPS \u2503 Duration \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Seq read (1MB) \u2502 claude.exe (227.4 MB) \u2502 770.4 MB/s \u2502 - \u2502 295.1 ms \u2502\n\u2502 Rand read (4K) \u2502 2593 files \u2502 21.2 MB/s \u2502 5422 \u2502 922.1 ms \u2502\n\u2502 Large bin cold \u2502 3 files \u2502 748.3 MB/s \u2502 - \u2502 847.2 ms \u2502\n\u2502 Large bin warm \u2502 3 files \u2502 22089.6 MB/s \u2502 - \u2502 28.7 ms \u2502\n\u2502 Small JS reads \u2502 113 files \u2502 2692.5 MB/s \u2502 316986 \u2502 15.8 ms \u2502\n\u2502 Metadata stat \u2502 6571 entries \u2502 - \u2502 174101 \u2502 37.7 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Storage Path Diagnostics \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 \u2503 \u2503 \u2503 Cold \u2503 \u2503 Rand \u2503 Rand \u2503\n\u2503 Path \u2503 FS \u2503 Write \u2503 Read \u2503 Warm Read \u2503 Read \u2503 Write \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 /root \u2502 virtiofs \u2502 1857.9 \u2502 3911.7 \u2502 4431.3 \u2502 53911 \u2502 6730 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /tmp \u2502 overlay \u2502 5221.7 \u2502 7990.2 \u2502 19932.5 \u2502 1330185 \u2502 5053 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /var/tmp \u2502 overlay \u2502 6542.2 \u2502 7688.8 \u2502 18062.5 \u2502 1609680 \u2502 4473 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /var/log \u2502 overlay \u2502 7032.4 \u2502 8884.7 \u2502 18182.9 \u2502 1664090 \u2502 4607 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 /run \u2502 overlay \u2502 7004.6 \u2502 9517.4 \u2502 19917.3 \u2502 1535096 \u2502 5239 IOPS \u2502\n\u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 MB/s \u2502 IOPS \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 944.4 \u2502 23888.5 \u2502 - \u2502 - \u2502\n\u2502 (227.4 \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 MB) \u2502 \u2502 \u2502 \u2502 \u2502 \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 1972.4 \u2502 17865.8 \u2502 - \u2502 - \u2502\n\u2502 (1.3 MB) \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 rootfs:\u2026 \u2502 overlay \u2502 - \u2502 1299.9 \u2502 24905.2 \u2502 - \u2502 - \u2502\n\u2502 (6.3 MB) \u2502 \u2502 \u2502 MB/s \u2502 MB/s \u2502 \u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Storage I/O Profile \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Path \u2503 Workload \u2503 Block \u2503 IOPS \u2503 Throughput \u2503 Avg Lat \u2503 P95 Lat \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 /root \u2502 seq_write \u2502 4k \u2502 15592 \u2502 60.9 MB/s \u2502 0.064 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 4k \u2502 1065098 \u2502 4160.5 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 4k \u2502 858241 \u2502 3352.5 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_write \u2502 64k \u2502 13408 \u2502 838.0 MB/s \u2502 0.075 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 64k \u2502 71519 \u2502 4469.9 MB/s \u2502 0.014 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 64k \u2502 70723 \u2502 4420.2 MB/s \u2502 0.014 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_write \u2502 1m \u2502 1839 \u2502 1839.3 MB/s \u2502 0.544 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_c\u2026 \u2502 1m \u2502 4665 \u2502 4664.7 MB/s \u2502 0.214 ms \u2502 - \u2502\n\u2502 /root \u2502 seq_read_w\u2026 \u2502 1m \u2502 4541 \u2502 4541.3 MB/s \u2502 0.22 ms \u2502 - \u2502\n\u2502 /root \u2502 read_4k \u2502 4k \u2502 44110 \u2502 172.3 MB/s \u2502 0.023 ms \u2502 0.033 ms \u2502\n\u2502 /root \u2502 write_4k_s\u2026 \u2502 4k \u2502 9257 \u2502 36.2 MB/s \u2502 0.108 ms \u2502 0.144 ms \u2502\n\u2502 /tmp \u2502 seq_write \u2502 4k \u2502 1290570 \u2502 5041.3 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 4k \u2502 1768137 \u2502 6906.8 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 4k \u2502 2441001 \u2502 9535.2 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_write \u2502 64k \u2502 116433 \u2502 7277.1 MB/s \u2502 0.009 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 64k \u2502 155985 \u2502 9749.0 MB/s \u2502 0.006 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 64k \u2502 312668 \u2502 19541.7 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /tmp \u2502 seq_write \u2502 1m \u2502 7163 \u2502 7163.0 MB/s \u2502 0.14 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_c\u2026 \u2502 1m \u2502 8779 \u2502 8779.2 MB/s \u2502 0.114 ms \u2502 - \u2502\n\u2502 /tmp \u2502 seq_read_w\u2026 \u2502 1m \u2502 19284 \u2502 19283.9 \u2502 0.052 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /tmp \u2502 read_4k \u2502 4k \u2502 32585 \u2502 127.3 MB/s \u2502 0.031 ms \u2502 0.05 ms \u2502\n\u2502 /tmp \u2502 write_4k_s\u2026 \u2502 4k \u2502 14324 \u2502 56.0 MB/s \u2502 0.07 ms \u2502 0.104 ms \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 4k \u2502 1476047 \u2502 5765.8 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 4k \u2502 1882029 \u2502 7351.7 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 4k \u2502 2400542 \u2502 9377.1 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 64k \u2502 111605 \u2502 6975.3 MB/s \u2502 0.009 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 64k \u2502 156365 \u2502 9772.8 MB/s \u2502 0.006 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 64k \u2502 276262 \u2502 17266.4 \u2502 0.004 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 seq_write \u2502 1m \u2502 7070 \u2502 7070.0 MB/s \u2502 0.141 ms \u2502 - \u2502\n\u2502 /var/tmp \u2502 seq_read_c\u2026 \u2502 1m \u2502 10394 \u2502 10394.0 \u2502 0.096 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 seq_read_w\u2026 \u2502 1m \u2502 20352 \u2502 20351.9 \u2502 0.049 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/tmp \u2502 read_4k \u2502 4k \u2502 34238 \u2502 133.7 MB/s \u2502 0.029 ms \u2502 0.045 ms \u2502\n\u2502 /var/tmp \u2502 write_4k_s\u2026 \u2502 4k \u2502 11939 \u2502 46.6 MB/s \u2502 0.084 ms \u2502 0.122 ms \u2502\n\u2502 /var/log \u2502 seq_write \u2502 4k \u2502 1457062 \u2502 5691.7 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 4k \u2502 1913693 \u2502 7475.4 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 4k \u2502 2429569 \u2502 9490.5 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_write \u2502 64k \u2502 104315 \u2502 6519.7 MB/s \u2502 0.01 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 64k \u2502 171591 \u2502 10724.5 \u2502 0.006 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 64k \u2502 291908 \u2502 18244.2 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 seq_write \u2502 1m \u2502 6401 \u2502 6401.3 MB/s \u2502 0.156 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_c\u2026 \u2502 1m \u2502 9935 \u2502 9934.9 MB/s \u2502 0.101 ms \u2502 - \u2502\n\u2502 /var/log \u2502 seq_read_w\u2026 \u2502 1m \u2502 19577 \u2502 19577.4 \u2502 0.051 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /var/log \u2502 read_4k \u2502 4k \u2502 32515 \u2502 127.0 MB/s \u2502 0.031 ms \u2502 0.051 ms \u2502\n\u2502 /var/log \u2502 write_4k_s\u2026 \u2502 4k \u2502 15291 \u2502 59.7 MB/s \u2502 0.065 ms \u2502 0.09 ms \u2502\n\u2502 /run \u2502 seq_write \u2502 4k \u2502 1410853 \u2502 5511.1 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 4k \u2502 1764155 \u2502 6891.2 MB/s \u2502 0.001 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 4k \u2502 2372258 \u2502 9266.6 MB/s \u2502 0.0 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_write \u2502 64k \u2502 114680 \u2502 7167.5 MB/s \u2502 0.009 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 64k \u2502 167801 \u2502 10487.6 \u2502 0.006 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 64k \u2502 305558 \u2502 19097.4 \u2502 0.003 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /run \u2502 seq_write \u2502 1m \u2502 6966 \u2502 6966.2 MB/s \u2502 0.144 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_c\u2026 \u2502 1m \u2502 9468 \u2502 9467.6 MB/s \u2502 0.106 ms \u2502 - \u2502\n\u2502 /run \u2502 seq_read_w\u2026 \u2502 1m \u2502 19348 \u2502 19347.8 \u2502 0.052 ms \u2502 - \u2502\n\u2502 \u2502 \u2502 \u2502 \u2502 MB/s \u2502 \u2502 \u2502\n\u2502 /run \u2502 read_4k \u2502 4k \u2502 28693 \u2502 112.1 MB/s \u2502 0.035 ms \u2502 0.059 ms \u2502\n\u2502 /run \u2502 write_4k_s\u2026 \u2502 4k \u2502 12733 \u2502 49.7 MB/s \u2502 0.079 ms \u2502 0.117 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n CLI Cold Start Latency [3 runs each] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Command \u2503 Min (ms) \u2503 Mean (ms) \u2503 Max (ms) \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 python3 \u2502 7.3 \u2502 7.5 \u2502 7.9 \u2502\n\u2502 node \u2502 134.6 \u2502 135.1 \u2502 136.0 \u2502\n\u2502 claude \u2502 398.6 \u2502 399.1 \u2502 399.6 \u2502\n\u2502 gemini \u2502 917.5 \u2502 935.7 \u2502 971.8 \u2502\n\u2502 codex \u2502 236.8 \u2502 255.8 \u2502 293.3 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n HTTP Benchmark \n [https://www.google.com/] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Metric \u2503 Value \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 Requests \u2502 50/50 \u2502\n\u2502 Concurrency \u2502 5 \u2502\n\u2502 Requests/sec \u2502 53.6 \u2502\n\u2502 Transfer \u2502 3.8 MB \u2502\n\u2502 Duration \u2502 932.0 ms \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 Latency min \u2502 53.0 ms \u2502\n\u2502 Latency mean \u2502 84.8 ms \u2502\n\u2502 Latency p50 \u2502 78.4 ms \u2502\n\u2502 Latency p95 \u2502 175.5 ms \u2502\n\u2502 Latency p99 \u2502 210.6 ms \u2502\n\u2502 Latency max \u2502 224.2 ms \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Proxy Throughput \n [https://cdn.elie.net/static/files/i-am-a-legend/i-am-a-legend-slides.pdf] \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Metric \u2503 Value \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 URL \u2502 https://cdn.elie.net/static/files/i-am-a-legend/i-am-a-legend-\u2026 \u2502\n\u2502 Downloaded \u2502 9.5 MB \u2502\n\u2502 Duration \u2502 0.44s \u2502\n\u2502 Throughput \u2502 21.46 MB/s \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Snapshot Operations (e2e via MCP) \n\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\n\u2503 Operation \u2503 Files \u2503 Latency (ms) \u2503 Status \u2503\n\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\n\u2502 create \u2502 10 files \u2502 961.9 \u2502 ok \u2502\n\u2502 list \u2502 10 files \u2502 389.7 \u2502 ok \u2502\n\u2502 changes \u2502 10 files \u2502 363.5 \u2502 ok \u2502\n\u2502 revert \u2502 10 files \u2502 364.1 \u2502 ok \u2502\n\u2502 delete \u2502 10 files \u2502 331.4 \u2502 ok \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 create \u2502 100 files \u2502 334.5 \u2502 ok \u2502\n\u2502 list \u2502 100 files \u2502 353.1 \u2502 ok \u2502\n\u2502 changes \u2502 100 files \u2502 349.1 \u2502 ok \u2502\n\u2502 revert \u2502 100 files \u2502 341.6 \u2502 ok \u2502\n\u2502 delete \u2502 100 files \u2502 350.3 \u2502 ok \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 create \u2502 500 files \u2502 344.4 \u2502 ok \u2502\n\u2502 list \u2502 500 files \u2502 365.5 \u2502 ok \u2502\n\u2502 changes \u2502 500 files \u2502 351.0 \u2502 ok \u2502\n\u2502 revert \u2502 500 files \u2502 312.5 \u2502 ok \u2502\n\u2502 delete \u2502 500 files \u2502 306.7 \u2502 ok \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\nJSON results saved to /tmp/capsem-benchmark.json\n" + } + ], + "schema": "capsem.benchmark-artifact.v1", + "project_version": "1.2.1780103109", + "arch": "arm64", + "recorded_at": 1780149901.6211681, + "recorded_at_utc": "2026-05-30T14:05:01.621174+00:00", + "command": "uv run pytest tests/capsem-serial/test_parallel_benchmark.py -xvs", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": true, + "source_dirty": false, + "dirty_paths": [ + "benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json", + "benchmarks/capsem-bench/data_1.2.1780103109_arm64.json", + "benchmarks/fork/data_1.2.1780103109_arm64.json", + "benchmarks/host-native/data_1.2.1780103109_arm64.json", + "benchmarks/lifecycle/data_1.2.1780103109_arm64.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json" + ] + } +} \ No newline at end of file diff --git a/benchmarks/security-engine/data_1.2.1779673506_arm64_dns_request_enforcement.json b/benchmarks/security-engine/data_1.2.1779673506_arm64_dns_request_enforcement.json deleted file mode 100644 index 4b800bb3..00000000 --- a/benchmarks/security-engine/data_1.2.1779673506_arm64_dns_request_enforcement.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "schema": "capsem.security-engine-benchmark.v1", - "kind": "vm_originated_dns_request_enforcement", - "version": "1.2.1779673506", - "source_commit": "69bd0e3d", - "timestamp": 1780076507.2018209, - "arch": "arm64", - "host": { - "sysname": "Darwin", - "release": "25.5.0", - "machine": "arm64" - }, - "command": "uv run pytest tests/capsem-serial/test_security_engine_benchmark.py::test_dns_request_enforcement_benchmark_records_vm_originated_path -xvs", - "workload": { - "event_family": "dns", - "event_type": "dns.request", - "source": "vm_originated", - "path": "guest_resolver_to_dns_proxy_to_security_engine" - }, - "runs": 8, - "gate_ms": 1000, - "rule": { - "id": "runtime.block-dns-bench.c12f32c0", - "pack_id": "runtime-benchmark", - "condition": "dns.request.qname == 'security-engine-bench-8eb16058.example.com'", - "decision": "block" - }, - "operations": { - "blocked_dns_request_ms": { - "min": 0.451, - "mean": 0.67, - "median": 0.483, - "p95": 1.994, - "p99": 1.994, - "max": 1.994, - "values": [ - 1.994, - 0.535, - 0.491, - 0.476, - 0.51, - 0.451, - 0.455, - 0.451 - ] - } - }, - "assertions": { - "session_db_security_events": { - "row_count": 16, - "distinct_event_ids": 16, - "blocked_count": 16, - "vm_id": "secdns-f6273b1c", - "profile_id": "profile-asset-boot", - "user_id": "elie", - "process_operation": null, - "process_command_class": null, - "rule_id": "runtime.block-dns-bench.c12f32c0", - "reason": "DNS request blocked by security benchmark" - }, - "session_db_dns_events": { - "row_count": 16, - "denied_count": 16, - "qname": "security-engine-bench-8eb16058.example.com", - "policy_mode": "runtime", - "policy_action": "block", - "policy_rule": "runtime.block-dns-bench.c12f32c0", - "policy_reason": "DNS request blocked by security benchmark" - }, - "runtime_match_count": 16, - "runtime_last_event_id": "dns-094f6541704920aa", - "logs_exposed_security_decision": true - } -} diff --git a/benchmarks/security-engine/data_1.2.1779673506_arm64_http_request_enforcement.json b/benchmarks/security-engine/data_1.2.1779673506_arm64_http_request_enforcement.json deleted file mode 100644 index 1d0dc8ad..00000000 --- a/benchmarks/security-engine/data_1.2.1779673506_arm64_http_request_enforcement.json +++ /dev/null @@ -1,345 +0,0 @@ -{ - "schema": "capsem.security-engine-benchmark.v1", - "kind": "vm_originated_http_request_enforcement", - "version": "1.2.1779673506", - "source_commit": "69bd0e3d", - "timestamp": 1780076504.970173, - "arch": "arm64", - "host": { - "sysname": "Darwin", - "release": "25.5.0", - "machine": "arm64" - }, - "command": "uv run pytest tests/capsem-serial/test_security_engine_benchmark.py::test_http_request_enforcement_benchmark_records_vm_originated_path -xvs", - "workload": { - "event_family": "network", - "event_type": "http.request", - "source": "vm_originated", - "path": "guest_curl_to_mitm_to_security_engine" - }, - "runs": 8, - "warmup_runs": 1, - "keepalive_runs": 8, - "gate_ms": 1000, - "rule": { - "id": "runtime.block-http-bench.d2c2c716", - "pack_id": "runtime-benchmark", - "condition": "http.request.host == 'example.com' && http.request.path == '/security-engine-bench-block-6bc405c5'", - "decision": "block" - }, - "operations": { - "blocked_http_request_wall_ms": { - "min": 5.075, - "mean": 5.258, - "median": 5.2, - "p95": 5.675, - "p99": 5.675, - "max": 5.675, - "values": [ - 5.675, - 5.337, - 5.214, - 5.132, - 5.075, - 5.281, - 5.163, - 5.186 - ] - }, - "blocked_http_request_starttransfer_ms": { - "min": 2.799, - "mean": 2.919, - "median": 2.889, - "p95": 3.055, - "p99": 3.055, - "max": 3.055, - "values": [ - 3.055, - 3.033, - 2.906, - 2.799, - 2.838, - 3.016, - 2.838, - 2.871 - ] - }, - "curl_phase_ms": { - "appconnect": { - "min": 2.426, - "mean": 2.531, - "median": 2.514, - "p95": 2.642, - "p99": 2.642, - "max": 2.642, - "values": [ - 2.642, - 2.629, - 2.534, - 2.426, - 2.495, - 2.605, - 2.456, - 2.463 - ] - }, - "connect": { - "min": 0.719, - "mean": 0.778, - "median": 0.771, - "p95": 0.851, - "p99": 0.851, - "max": 0.851, - "values": [ - 0.832, - 0.851, - 0.816, - 0.719, - 0.787, - 0.733, - 0.755, - 0.729 - ] - }, - "namelookup": { - "min": 0.692, - "mean": 0.751, - "median": 0.744, - "p95": 0.82, - "p99": 0.82, - "max": 0.82, - "values": [ - 0.798, - 0.82, - 0.791, - 0.692, - 0.759, - 0.711, - 0.73, - 0.704 - ] - }, - "pretransfer": { - "min": 2.441, - "mean": 2.549, - "median": 2.529, - "p95": 2.656, - "p99": 2.656, - "max": 2.656, - "values": [ - 2.656, - 2.643, - 2.548, - 2.441, - 2.507, - 2.62, - 2.468, - 2.51 - ] - }, - "starttransfer": { - "min": 2.799, - "mean": 2.919, - "median": 2.889, - "p95": 3.055, - "p99": 3.055, - "max": 3.055, - "values": [ - 3.055, - 3.033, - 2.906, - 2.799, - 2.838, - 3.016, - 2.838, - 2.871 - ] - }, - "total": { - "min": 2.807, - "mean": 2.929, - "median": 2.899, - "p95": 3.066, - "p99": 3.066, - "max": 3.066, - "values": [ - 3.066, - 3.043, - 2.916, - 2.807, - 2.848, - 3.026, - 2.848, - 2.881 - ] - } - }, - "curl_phase_delta_ms": { - "dns": { - "min": 0.692, - "mean": 0.751, - "median": 0.744, - "p95": 0.82, - "p99": 0.82, - "max": 0.82, - "values": [ - 0.798, - 0.82, - 0.791, - 0.692, - 0.759, - 0.711, - 0.73, - 0.704 - ] - }, - "pretransfer_after_tls": { - "min": 0.012, - "mean": 0.018, - "median": 0.014, - "p95": 0.047, - "p99": 0.047, - "max": 0.047, - "values": [ - 0.014, - 0.014, - 0.014, - 0.015, - 0.012, - 0.015, - 0.012, - 0.047 - ] - }, - "response_tail_after_first_byte": { - "min": 0.008, - "mean": 0.01, - "median": 0.01, - "p95": 0.011, - "p99": 0.011, - "max": 0.011, - "values": [ - 0.011, - 0.01, - 0.01, - 0.008, - 0.01, - 0.01, - 0.01, - 0.01 - ] - }, - "server_first_byte_after_pretransfer": { - "min": 0.331, - "mean": 0.37, - "median": 0.365, - "p95": 0.399, - "p99": 0.399, - "max": 0.399, - "values": [ - 0.399, - 0.39, - 0.358, - 0.358, - 0.331, - 0.396, - 0.37, - 0.361 - ] - }, - "tcp_connect": { - "min": 0.022, - "mean": 0.027, - "median": 0.026, - "p95": 0.034, - "p99": 0.034, - "max": 0.034, - "values": [ - 0.034, - 0.031, - 0.025, - 0.027, - 0.028, - 0.022, - 0.025, - 0.025 - ] - }, - "tls_appconnect": { - "min": 1.701, - "mean": 1.754, - "median": 1.726, - "p95": 1.872, - "p99": 1.872, - "max": 1.872, - "values": [ - 1.81, - 1.778, - 1.718, - 1.707, - 1.708, - 1.872, - 1.701, - 1.734 - ] - } - }, - "keepalive_http_request_starttransfer_ms": { - "min": 0.261, - "mean": 0.294, - "median": 0.274, - "p95": 0.404, - "p99": 0.404, - "max": 0.404, - "values": [ - 0.404, - 0.313, - 0.284, - 0.265, - 0.261, - 0.295, - 0.265, - 0.262 - ] - }, - "keepalive_http_request_total_ms": { - "min": 0.263, - "mean": 0.297, - "median": 0.28, - "p95": 0.411, - "p99": 0.411, - "max": 0.411, - "values": [ - 0.411, - 0.316, - 0.291, - 0.268, - 0.263, - 0.297, - 0.267, - 0.263 - ] - }, - "keepalive_connection_ms": { - "connect_ms": 14.111, - "tls_handshake_ms": 1.17 - } - }, - "assertions": { - "session_db_security_events": { - "row_count": 17, - "distinct_event_ids": 17, - "blocked_count": 17, - "vm_id": "sechttp-057af491", - "profile_id": "profile-asset-boot", - "user_id": "elie", - "process_operation": null, - "process_command_class": null, - "rule_id": "runtime.block-http-bench.d2c2c716", - "reason": "HTTP request blocked by security benchmark" - }, - "runtime_match_count": 17, - "runtime_last_event_id": "net-http-c4a3b556b30130ec", - "logs_exposed_security_decision": true - } -} diff --git a/benchmarks/security-engine/data_1.2.1779673506_arm64_mcp_request_enforcement.json b/benchmarks/security-engine/data_1.2.1779673506_arm64_mcp_request_enforcement.json deleted file mode 100644 index 9bbd7c4a..00000000 --- a/benchmarks/security-engine/data_1.2.1779673506_arm64_mcp_request_enforcement.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "schema": "capsem.security-engine-benchmark.v1", - "kind": "vm_originated_mcp_request_enforcement", - "version": "1.2.1779673506", - "source_commit": "69bd0e3d", - "timestamp": 1780076509.451194, - "arch": "arm64", - "host": { - "sysname": "Darwin", - "release": "25.5.0", - "machine": "arm64" - }, - "command": "uv run pytest tests/capsem-serial/test_security_engine_benchmark.py::test_mcp_request_enforcement_benchmark_records_vm_originated_path -xvs", - "workload": { - "event_family": "mcp", - "event_type": "mcp.request", - "source": "vm_originated", - "path": "guest_mcp_server_to_framed_vsock_to_security_engine" - }, - "runs": 8, - "gate_ms": 1000, - "rule": { - "id": "runtime.block-mcp-bench.f2989306", - "pack_id": "runtime-benchmark", - "condition": "mcp.request.server_id == 'local' && mcp.request.tool_name == 'echo'", - "decision": "block" - }, - "operations": { - "blocked_mcp_request_ms": { - "min": 0.185, - "mean": 0.253, - "median": 0.203, - "p95": 0.591, - "p99": 0.591, - "max": 0.591, - "values": [ - 0.591, - 0.212, - 0.194, - 0.242, - 0.192, - 0.185, - 0.189, - 0.216 - ] - } - }, - "assertions": { - "session_db_security_events": { - "row_count": 8, - "distinct_event_ids": 8, - "blocked_count": 8, - "vm_id": "secmcp-f21068a1", - "profile_id": "profile-asset-boot", - "user_id": "elie", - "process_operation": null, - "process_command_class": null, - "rule_id": "runtime.block-mcp-bench.f2989306", - "reason": "MCP request blocked by security benchmark" - }, - "session_db_mcp_calls": { - "row_count": 8, - "denied_count": 8, - "server_name": "local", - "tool_name": "local__echo", - "policy_mode": "enforce", - "policy_action": "block", - "policy_rule": "runtime.block-mcp-bench.f2989306", - "policy_reason": "MCP request blocked by security benchmark" - }, - "runtime_match_count": 8, - "runtime_last_event_id": "mcp-dcc79d230c8bff98", - "logs_exposed_security_decision": true - } -} diff --git a/benchmarks/security-engine/data_1.2.1779673506_arm64_process_enforcement.json b/benchmarks/security-engine/data_1.2.1779673506_arm64_process_enforcement.json deleted file mode 100644 index 68b76d1a..00000000 --- a/benchmarks/security-engine/data_1.2.1779673506_arm64_process_enforcement.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "schema": "capsem.security-engine-benchmark.v1", - "kind": "vm_originated_process_enforcement", - "version": "1.2.1779673506", - "source_commit": "69bd0e3d", - "timestamp": 1780076502.590691, - "arch": "arm64", - "host": { - "sysname": "Darwin", - "release": "25.5.0", - "machine": "arm64" - }, - "command": "uv run pytest tests/capsem-serial/test_security_engine_benchmark.py -xvs", - "workload": { - "event_family": "process", - "event_type": "process.exec", - "source": "vm_originated", - "path": "service_api_to_capsem_process_to_security_engine" - }, - "runs": 8, - "gate_ms": 750, - "rule": { - "id": "runtime.block-shell-bench.0c50ba9d", - "pack_id": "runtime-benchmark", - "condition": "process.activity.operation == 'exec' && process.activity.command_class == 'shell'", - "decision": "block" - }, - "operations": { - "blocked_process_exec_ms": { - "min": 6.776, - "mean": 7.315, - "median": 7.123, - "p95": 8.658, - "p99": 8.658, - "max": 8.658, - "values": [ - 8.658, - 7.351, - 7.22, - 7.027, - 6.948, - 7.542, - 6.776, - 6.994 - ] - } - }, - "assertions": { - "session_db_security_events": { - "row_count": 8, - "distinct_event_ids": 8, - "blocked_count": 8, - "vm_id": "secbench-631fed04", - "profile_id": "profile-asset-boot", - "user_id": "elie", - "process_operation": "exec", - "process_command_class": "shell", - "rule_id": "runtime.block-shell-bench.0c50ba9d", - "reason": "shell exec blocked by security benchmark" - }, - "runtime_match_count": 8, - "runtime_last_event_id": "process-4b19ec510dba73ae", - "logs_exposed_security_decision": true - } -} diff --git a/benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json b/benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json new file mode 100644 index 00000000..1ce87834 --- /dev/null +++ b/benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json @@ -0,0 +1,783 @@ +{ + "schema": "capsem.security-engine-benchmark.v1", + "kind": "criterion_cel_microbench", + "source_commit": "0a425541", + "profile": { + "cargo_profile": "bench", + "criterion_samples": 100, + "criterion_warmup_seconds": 3, + "criterion_target_seconds": 5 + }, + "scope": { + "vm_originated": false, + "notes": [ + "Host-side microbenchmark only.", + "Measures canonical policy-context CEL paths, detection evaluation, backtest dedupe, runtime registry operations, compiled-plan rebuild cost, and native lookup comparators.", + "Does not include guest transport, service IPC, Security Engine emitter, or session.db journal write latency." + ] + }, + "measurements": [ + { + "group": "security_engine_backtest_dedupe", + "name": "dedupe_1000_rows_100_unique_limit_100", + "full_id": "security_engine_backtest_dedupe/dedupe_1000_rows_100_unique_limit_100", + "estimate_kind": "slope", + "estimate_ns": 169765.76354120485, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 168948.6094514328, + "upper_bound": 170622.6829797996 + }, + "estimate_standard_error_ns": 426.86650908326123, + "mean_ns": 171216.60629213433, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 170292.6807284613, + "upper_bound": 172210.95309741155 + }, + "median_ns": 170047.25065322884, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 169286.69047619047, + "upper_bound": 171392.61366959065 + }, + "slope_ns": 169765.76354120485, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 168948.6094514328, + "upper_bound": 170622.6829797996 + }, + "slope_standard_error_ns": 426.86650908326123 + }, + { + "group": "security_engine_backtest_dedupe", + "name": "dedupe_100_unique_limit_100", + "full_id": "security_engine_backtest_dedupe/dedupe_100_unique_limit_100", + "estimate_kind": "slope", + "estimate_ns": 19643.4602894091, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 19552.792439124063, + "upper_bound": 19737.559866098803 + }, + "estimate_standard_error_ns": 47.102449961664554, + "mean_ns": 19659.581435361728, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 19586.21140557635, + "upper_bound": 19734.076385783923 + }, + "median_ns": 19685.2679698415, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 19570.28081232493, + "upper_bound": 19759.64892623716 + }, + "slope_ns": 19643.4602894091, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 19552.792439124063, + "upper_bound": 19737.559866098803 + }, + "slope_standard_error_ns": 47.102449961664554 + }, + { + "group": "security_engine_cel_compile", + "name": "canonical_http_policy", + "full_id": "security_engine_cel_compile/canonical_http_policy", + "estimate_kind": "slope", + "estimate_ns": 41718.609581917146, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 41224.817075029096, + "upper_bound": 42293.88939537181 + }, + "estimate_standard_error_ns": 273.085274173881, + "mean_ns": 41933.07568435598, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 41397.779468702225, + "upper_bound": 42503.21114396413 + }, + "median_ns": 40700.916075650115, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 40347.49038461538, + "upper_bound": 41581.90202702703 + }, + "slope_ns": 41718.609581917146, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 41224.817075029096, + "upper_bound": 42293.88939537181 + }, + "slope_standard_error_ns": 273.085274173881 + }, + { + "group": "security_engine_cel_compile", + "name": "header_authorization_exists", + "full_id": "security_engine_cel_compile/header_authorization_exists", + "estimate_kind": "slope", + "estimate_ns": 8616.623499101677, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 8567.865543645008, + "upper_bound": 8688.037120183591 + }, + "estimate_standard_error_ns": 31.431303882251754, + "mean_ns": 8646.883889357558, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 8608.062641921015, + "upper_bound": 8697.130460181477 + }, + "median_ns": 8608.474806073971, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 8584.462183235868, + "upper_bound": 8642.336231884059 + }, + "slope_ns": 8616.623499101677, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 8567.865543645008, + "upper_bound": 8688.037120183591 + }, + "slope_standard_error_ns": 31.431303882251754 + }, + { + "group": "security_engine_cel_compile", + "name": "host_contains_google", + "full_id": "security_engine_cel_compile/host_contains_google", + "estimate_kind": "slope", + "estimate_ns": 8649.469081196416, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 8632.146142493075, + "upper_bound": 8665.754753135845 + }, + "estimate_standard_error_ns": 8.584168809417832, + "mean_ns": 8638.20767255532, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 8622.42341217505, + "upper_bound": 8654.950240387221 + }, + "median_ns": 8639.604678362573, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 8626.475019461672, + "upper_bound": 8647.890023566379 + }, + "slope_ns": 8649.469081196416, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 8632.146142493075, + "upper_bound": 8665.754753135845 + }, + "slope_standard_error_ns": 8.584168809417832 + }, + { + "group": "security_engine_cel_evaluate", + "name": "body_contains_secret", + "full_id": "security_engine_cel_evaluate/body_contains_secret", + "estimate_kind": "slope", + "estimate_ns": 19632.66307365065, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 18775.692965899267, + "upper_bound": 20533.670403222477 + }, + "estimate_standard_error_ns": 449.75127613926065, + "mean_ns": 17565.682046035974, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 16855.14303723588, + "upper_bound": 18317.033249668944 + }, + "median_ns": 15223.358585858587, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14833.491895990255, + "upper_bound": 17712.206597222223 + }, + "slope_ns": 19632.66307365065, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 18775.692965899267, + "upper_bound": 20533.670403222477 + }, + "slope_standard_error_ns": 449.75127613926065 + }, + { + "group": "security_engine_cel_evaluate", + "name": "canonical_http_policy", + "full_id": "security_engine_cel_evaluate/canonical_http_policy", + "estimate_kind": "slope", + "estimate_ns": 23654.551517587144, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 23295.368881249542, + "upper_bound": 24018.33365434777 + }, + "estimate_standard_error_ns": 184.92987734881797, + "mean_ns": 23354.650129986963, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 23137.079444453, + "upper_bound": 23592.87084374474 + }, + "median_ns": 22954.423742201732, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 22730.390798226163, + "upper_bound": 23170.869222372778 + }, + "slope_ns": 23654.551517587144, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 23295.368881249542, + "upper_bound": 24018.33365434777 + }, + "slope_standard_error_ns": 184.92987734881797 + }, + { + "group": "security_engine_cel_evaluate", + "name": "canonical_http_policy_last_match_100_rules", + "full_id": "security_engine_cel_evaluate/canonical_http_policy_last_match_100_rules", + "estimate_kind": "slope", + "estimate_ns": 1287960.3025565243, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1284711.6400885107, + "upper_bound": 1291444.1344560254 + }, + "estimate_standard_error_ns": 1710.4207686994357, + "mean_ns": 1288414.0467362802, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1285448.5141307209, + "upper_bound": 1291464.5838861351 + }, + "median_ns": 1285466.1458333335, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1283516.0500578703, + "upper_bound": 1288519.3452380951 + }, + "slope_ns": 1287960.3025565243, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1284711.6400885107, + "upper_bound": 1291444.1344560254 + }, + "slope_standard_error_ns": 1710.4207686994357 + }, + { + "group": "security_engine_cel_evaluate", + "name": "header_authorization_exists", + "full_id": "security_engine_cel_evaluate/header_authorization_exists", + "estimate_kind": "slope", + "estimate_ns": 16276.505282579152, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 16194.231808248274, + "upper_bound": 16387.62454698775 + }, + "estimate_standard_error_ns": 50.180476651381504, + "mean_ns": 16270.042171429142, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 16214.527098586868, + "upper_bound": 16330.087184283091 + }, + "median_ns": 16244.39186764726, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 16195.171518138395, + "upper_bound": 16285.002107728336 + }, + "slope_ns": 16276.505282579152, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 16194.231808248274, + "upper_bound": 16387.62454698775 + }, + "slope_standard_error_ns": 50.180476651381504 + }, + { + "group": "security_engine_cel_evaluate", + "name": "host_contains_google", + "full_id": "security_engine_cel_evaluate/host_contains_google", + "estimate_kind": "slope", + "estimate_ns": 14632.247152357028, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14488.3952780408, + "upper_bound": 14795.354188064981 + }, + "estimate_standard_error_ns": 78.58762769461745, + "mean_ns": 14540.814346765674, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14441.685386020818, + "upper_bound": 14650.448341172358 + }, + "median_ns": 14381.457539682538, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14293.054761904761, + "upper_bound": 14422.896957343732 + }, + "slope_ns": 14632.247152357028, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14488.3952780408, + "upper_bound": 14795.354188064981 + }, + "slope_standard_error_ns": 78.58762769461745 + }, + { + "group": "security_engine_cel_evaluate", + "name": "path_starts_admin", + "full_id": "security_engine_cel_evaluate/path_starts_admin", + "estimate_kind": "slope", + "estimate_ns": 14508.100129186183, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14456.007960182962, + "upper_bound": 14561.990739449777 + }, + "estimate_standard_error_ns": 27.032526114726025, + "mean_ns": 14537.537372544028, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14467.188986800234, + "upper_bound": 14632.85408665708 + }, + "median_ns": 14499.13656918344, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14470.85984446801, + "upper_bound": 14524.517391304347 + }, + "slope_ns": 14508.100129186183, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14456.007960182962, + "upper_bound": 14561.990739449777 + }, + "slope_standard_error_ns": 27.032526114726025 + }, + { + "group": "security_engine_cel_evaluate", + "name": "url_contains_google", + "full_id": "security_engine_cel_evaluate/url_contains_google", + "estimate_kind": "slope", + "estimate_ns": 14258.134954674999, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14211.341365242857, + "upper_bound": 14307.707069331746 + }, + "estimate_standard_error_ns": 24.559890730385774, + "mean_ns": 14235.942664333203, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14198.738975098966, + "upper_bound": 14275.447315539737 + }, + "median_ns": 14228.6728613159, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14158.2943793911, + "upper_bound": 14277.848979591838 + }, + "slope_ns": 14258.134954674999, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 14211.341365242857, + "upper_bound": 14307.707069331746 + }, + "slope_standard_error_ns": 24.559890730385774 + }, + { + "group": "security_engine_detection_evaluate", + "name": "canonical_http_policy_last_match_100_rules", + "full_id": "security_engine_detection_evaluate/canonical_http_policy_last_match_100_rules", + "estimate_kind": "slope", + "estimate_ns": 1289735.495806118, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1285890.2017830922, + "upper_bound": 1293567.7830477143 + }, + "estimate_standard_error_ns": 1959.687046365984, + "mean_ns": 1291649.4525483807, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1284217.3200951158, + "upper_bound": 1300828.4299138242 + }, + "median_ns": 1283481.7298245616, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1280409.6153846155, + "upper_bound": 1287562.6042105262 + }, + "slope_ns": 1289735.495806118, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1285890.2017830922, + "upper_bound": 1293567.7830477143 + }, + "slope_standard_error_ns": 1959.687046365984 + }, + { + "group": "security_engine_detection_evaluate", + "name": "canonical_http_policy_single_rule", + "full_id": "security_engine_detection_evaluate/canonical_http_policy_single_rule", + "estimate_kind": "slope", + "estimate_ns": 23534.05278069702, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 23395.27925850916, + "upper_bound": 23686.89650204526 + }, + "estimate_standard_error_ns": 74.70411278258345, + "mean_ns": 23492.61610246049, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 23394.70987069618, + "upper_bound": 23602.553392010082 + }, + "median_ns": 23342.58967284194, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 23274.347783810066, + "upper_bound": 23423.87354651163 + }, + "slope_ns": 23534.05278069702, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 23395.27925850916, + "upper_bound": 23686.89650204526 + }, + "slope_standard_error_ns": 74.70411278258345 + }, + { + "group": "security_engine_native_lookup", + "name": "canonical_http_policy", + "full_id": "security_engine_native_lookup/canonical_http_policy", + "estimate_kind": "slope", + "estimate_ns": 11.56022872300204, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 11.486627050790734, + "upper_bound": 11.663479639473673 + }, + "estimate_standard_error_ns": 0.04590679008584831, + "mean_ns": 11.573955306622276, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 11.514307633577902, + "upper_bound": 11.650394647595308 + }, + "median_ns": 11.478722441406909, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 11.44869461383292, + "upper_bound": 11.50303918298771 + }, + "slope_ns": 11.56022872300204, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 11.486627050790734, + "upper_bound": 11.663479639473673 + }, + "slope_standard_error_ns": 0.04590679008584831 + }, + { + "group": "security_engine_policy_context", + "name": "project_and_serialize_policy_context", + "full_id": "security_engine_policy_context/project_and_serialize_policy_context", + "estimate_kind": "slope", + "estimate_ns": 2583.876898586795, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 2572.9095306138915, + "upper_bound": 2596.388245504242 + }, + "estimate_standard_error_ns": 5.969008483359895, + "mean_ns": 2597.9580933568045, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 2585.2559255374053, + "upper_bound": 2610.3152871649054 + }, + "median_ns": 2601.156008611272, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 2580.2553960659225, + "upper_bound": 2618.791533758639 + }, + "slope_ns": 2583.876898586795, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 2572.9095306138915, + "upper_bound": 2596.388245504242 + }, + "slope_standard_error_ns": 5.969008483359895 + }, + { + "group": "security_engine_policy_context", + "name": "project_security_event_to_policy_context", + "full_id": "security_engine_policy_context/project_security_event_to_policy_context", + "estimate_kind": "slope", + "estimate_ns": 536.2613986337147, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 534.2144146218218, + "upper_bound": 538.6022172536678 + }, + "estimate_standard_error_ns": 1.1213512408129251, + "mean_ns": 543.8344729071761, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 540.2625613239089, + "upper_bound": 547.7085047252102 + }, + "median_ns": 538.7772053950159, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 536.672758152174, + "upper_bound": 541.2512341485508 + }, + "slope_ns": 536.2613986337147, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 534.2144146218218, + "upper_bound": 538.6022172536678 + }, + "slope_standard_error_ns": 1.1213512408129251 + }, + { + "group": "security_engine_runtime_registry", + "name": "add_or_update_single_rule", + "full_id": "security_engine_runtime_registry/add_or_update_single_rule", + "estimate_kind": "slope", + "estimate_ns": 148.80511032943258, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 148.31668130393106, + "upper_bound": 149.321235256347 + }, + "estimate_standard_error_ns": 0.25595267601626276, + "mean_ns": 149.66902817328435, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 149.19307129291133, + "upper_bound": 150.15543405950103 + }, + "median_ns": 149.31438234798117, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 148.7471829882897, + "upper_bound": 150.4720254798658 + }, + "slope_ns": 148.80511032943258, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 148.31668130393106, + "upper_bound": 149.321235256347 + }, + "slope_standard_error_ns": 0.25595267601626276 + }, + { + "group": "security_engine_runtime_registry", + "name": "enabled_enforcement_rules_100_rules", + "full_id": "security_engine_runtime_registry/enabled_enforcement_rules_100_rules", + "estimate_kind": "slope", + "estimate_ns": 7631.368825295581, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 7600.289080368191, + "upper_bound": 7661.7625743694225 + }, + "estimate_standard_error_ns": 15.702187492549706, + "mean_ns": 7617.5916796176325, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 7587.812324380978, + "upper_bound": 7647.766577151961 + }, + "median_ns": 7614.80891642548, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 7583.649097564796, + "upper_bound": 7661.639985014985 + }, + "slope_ns": 7631.368825295581, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 7600.289080368191, + "upper_bound": 7661.7625743694225 + }, + "slope_standard_error_ns": 15.702187492549706 + }, + { + "group": "security_engine_runtime_registry", + "name": "project_and_compile_detection_100_rules", + "full_id": "security_engine_runtime_registry/project_and_compile_detection_100_rules", + "estimate_kind": "slope", + "estimate_ns": 316875.4547251367, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 315314.8606088706, + "upper_bound": 318445.0754923803 + }, + "estimate_standard_error_ns": 798.071279615365, + "mean_ns": 318680.90039144084, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 316509.76560837973, + "upper_bound": 321363.445182746 + }, + "median_ns": 317208.4780595813, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 315323.26666666666, + "upper_bound": 318262.5890151515 + }, + "slope_ns": 316875.4547251367, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 315314.8606088706, + "upper_bound": 318445.0754923803 + }, + "slope_standard_error_ns": 798.071279615365 + }, + { + "group": "security_engine_runtime_registry", + "name": "project_and_compile_enforcement_100_rules", + "full_id": "security_engine_runtime_registry/project_and_compile_enforcement_100_rules", + "estimate_kind": "slope", + "estimate_ns": 321268.87703783065, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 310757.6904121714, + "upper_bound": 333952.6763315121 + }, + "estimate_standard_error_ns": 5962.033579203133, + "mean_ns": 311040.57187868236, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 305960.58828087687, + "upper_bound": 317243.9182259018 + }, + "median_ns": 304068.9513888889, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 302045.18506493507, + "upper_bound": 306882.3776041667 + }, + "slope_ns": 321268.87703783065, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 310757.6904121714, + "upper_bound": 333952.6763315121 + }, + "slope_standard_error_ns": 5962.033579203133 + }, + { + "group": "security_engine_runtime_registry", + "name": "rebuild_engine_from_100_enforcement_100_detection", + "full_id": "security_engine_runtime_registry/rebuild_engine_from_100_enforcement_100_detection", + "estimate_kind": "slope", + "estimate_ns": 610565.8707388799, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 607722.8328919687, + "upper_bound": 613754.440881424 + }, + "estimate_standard_error_ns": 1538.9976078734742, + "mean_ns": 614268.2281117368, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 612023.6885140041, + "upper_bound": 616569.9551478114 + }, + "median_ns": 614474.9083867521, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 611592.5147058824, + "upper_bound": 617119.7964703424 + }, + "slope_ns": 610565.8707388799, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 607722.8328919687, + "upper_bound": 613754.440881424 + }, + "slope_standard_error_ns": 1538.9976078734742 + }, + { + "group": "security_engine_runtime_registry", + "name": "update_existing_then_rebuild_100_rule_plan", + "full_id": "security_engine_runtime_registry/update_existing_then_rebuild_100_rule_plan", + "estimate_kind": "slope", + "estimate_ns": 361728.1459317275, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 360772.6204630322, + "upper_bound": 362586.44339550304 + }, + "estimate_standard_error_ns": 460.686497051645, + "mean_ns": 357293.97890017286, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 355779.0692021401, + "upper_bound": 358743.9188202766 + }, + "median_ns": 358394.9126425218, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 356585.29285714286, + "upper_bound": 360225.1076923077 + }, + "slope_ns": 361728.1459317275, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 360772.6204630322, + "upper_bound": 362586.44339550304 + }, + "slope_standard_error_ns": 460.686497051645 + } + ], + "project_version": "1.2.1780103109", + "arch": "arm64", + "recorded_at": 1780149808.979703, + "recorded_at_utc": "2026-05-30T14:03:28.979706+00:00", + "command": "cargo bench -p capsem-security-engine --bench security_engine_cel", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": false, + "source_dirty": false, + "dirty_paths": [] + } +} diff --git a/benchmarks/security-engine/data_1.2.1780103109_arm64_dns_request_enforcement.json b/benchmarks/security-engine/data_1.2.1780103109_arm64_dns_request_enforcement.json new file mode 100644 index 00000000..85f43d61 --- /dev/null +++ b/benchmarks/security-engine/data_1.2.1780103109_arm64_dns_request_enforcement.json @@ -0,0 +1,104 @@ +{ + "schema": "capsem.security-engine-benchmark.v1", + "kind": "vm_originated_dns_request_enforcement", + "version": "1.2.1780103109", + "source_commit": "0a425541", + "timestamp": 1780149908.35654, + "arch": "arm64", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "command": "uv run pytest tests/capsem-serial/test_security_engine_benchmark.py::test_dns_request_enforcement_benchmark_records_vm_originated_path -xvs", + "workload": { + "event_family": "dns", + "event_type": "dns.request", + "source": "vm_originated", + "path": "guest_resolver_to_dns_proxy_to_security_engine" + }, + "runs": 8, + "gate_ms": 1000, + "rule": { + "id": "runtime.block-dns-bench.92040423", + "pack_id": "runtime-benchmark", + "condition": "dns.request.qname == 'security-engine-bench-d006d8eb.example.com'", + "decision": "block" + }, + "operations": { + "blocked_dns_request_ms": { + "min": 0.403, + "mean": 0.729, + "median": 0.435, + "p95": 2.758, + "p99": 2.758, + "max": 2.758, + "values": [ + 2.758, + 0.498, + 0.429, + 0.409, + 0.403, + 0.466, + 0.441, + 0.428 + ] + } + }, + "assertions": { + "session_db_security_events": { + "row_count": 16, + "distinct_event_ids": 16, + "blocked_count": 16, + "vm_id": "secdns-c171f156", + "profile_id": "profile-asset-boot", + "user_id": "elie", + "process_operation": null, + "process_command_class": null, + "rule_id": "runtime.block-dns-bench.92040423", + "reason": "DNS request blocked by security benchmark" + }, + "session_db_dns_events": { + "row_count": 16, + "denied_count": 16, + "qname": "security-engine-bench-d006d8eb.example.com", + "policy_mode": "runtime", + "policy_action": "block", + "policy_rule": "runtime.block-dns-bench.92040423", + "policy_reason": "DNS request blocked by security benchmark" + }, + "runtime_match_count": 16, + "runtime_last_event_id": "dns-aeca09eb3090d8fa", + "logs_exposed_security_decision": true + }, + "project_version": "1.2.1780103109", + "recorded_at": 1780149908.356926, + "recorded_at_utc": "2026-05-30T14:05:08.356927+00:00", + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": true, + "source_dirty": false, + "dirty_paths": [ + "benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json", + "benchmarks/capsem-bench/data_1.2.1780103109_arm64.json", + "benchmarks/fork/data_1.2.1780103109_arm64.json", + "benchmarks/host-native/data_1.2.1780103109_arm64.json", + "benchmarks/lifecycle/data_1.2.1780103109_arm64.json", + "benchmarks/parallel/data_1.2.1780103109_arm64.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_http_request_enforcement.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_process_enforcement.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json" + ] + } +} diff --git a/benchmarks/security-engine/data_1.2.1780103109_arm64_http_request_enforcement.json b/benchmarks/security-engine/data_1.2.1780103109_arm64_http_request_enforcement.json new file mode 100644 index 00000000..d4bcbb45 --- /dev/null +++ b/benchmarks/security-engine/data_1.2.1780103109_arm64_http_request_enforcement.json @@ -0,0 +1,374 @@ +{ + "schema": "capsem.security-engine-benchmark.v1", + "kind": "vm_originated_http_request_enforcement", + "version": "1.2.1780103109", + "source_commit": "0a425541", + "timestamp": 1780149906.213564, + "arch": "arm64", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "command": "uv run pytest tests/capsem-serial/test_security_engine_benchmark.py::test_http_request_enforcement_benchmark_records_vm_originated_path -xvs", + "workload": { + "event_family": "network", + "event_type": "http.request", + "source": "vm_originated", + "path": "guest_curl_to_mitm_to_security_engine" + }, + "runs": 8, + "warmup_runs": 1, + "keepalive_runs": 8, + "gate_ms": 1000, + "rule": { + "id": "runtime.block-http-bench.4be78026", + "pack_id": "runtime-benchmark", + "condition": "http.request.host == 'example.com' && http.request.path == '/security-engine-bench-block-dca4f33f'", + "decision": "block" + }, + "operations": { + "blocked_http_request_wall_ms": { + "min": 5.378, + "mean": 7.142, + "median": 5.858, + "p95": 12.245, + "p99": 12.245, + "max": 12.245, + "values": [ + 10.628, + 12.245, + 6.012, + 6.192, + 5.704, + 5.448, + 5.531, + 5.378 + ] + }, + "blocked_http_request_starttransfer_ms": { + "min": 2.788, + "mean": 3.199, + "median": 3.097, + "p95": 3.668, + "p99": 3.668, + "max": 3.668, + "values": [ + 3.668, + 3.596, + 3.083, + 3.418, + 3.067, + 2.788, + 3.111, + 2.861 + ] + }, + "curl_phase_ms": { + "appconnect": { + "min": 2.263, + "mean": 2.732, + "median": 2.635, + "p95": 3.156, + "p99": 3.156, + "max": 3.156, + "values": [ + 3.156, + 3.133, + 2.587, + 3.016, + 2.618, + 2.263, + 2.651, + 2.43 + ] + }, + "connect": { + "min": 0.815, + "mean": 1.002, + "median": 0.956, + "p95": 1.383, + "p99": 1.383, + "max": 1.383, + "values": [ + 0.958, + 1.383, + 0.955, + 0.961, + 0.955, + 0.815, + 1.112, + 0.874 + ] + }, + "namelookup": { + "min": 0.788, + "mean": 0.925, + "median": 0.919, + "p95": 1.079, + "p99": 1.079, + "max": 1.079, + "values": [ + 0.918, + 1.045, + 0.92, + 0.924, + 0.902, + 0.788, + 1.079, + 0.822 + ] + }, + "pretransfer": { + "min": 2.278, + "mean": 2.749, + "median": 2.647, + "p95": 3.178, + "p99": 3.178, + "max": 3.178, + "values": [ + 3.178, + 3.153, + 2.61, + 3.034, + 2.633, + 2.278, + 2.661, + 2.446 + ] + }, + "starttransfer": { + "min": 2.788, + "mean": 3.199, + "median": 3.097, + "p95": 3.668, + "p99": 3.668, + "max": 3.668, + "values": [ + 3.668, + 3.596, + 3.083, + 3.418, + 3.067, + 2.788, + 3.111, + 2.861 + ] + }, + "total": { + "min": 2.797, + "mean": 3.209, + "median": 3.106, + "p95": 3.68, + "p99": 3.68, + "max": 3.68, + "values": [ + 3.68, + 3.607, + 3.094, + 3.429, + 3.078, + 2.797, + 3.119, + 2.871 + ] + } + }, + "curl_phase_delta_ms": { + "dns": { + "min": 0.788, + "mean": 0.925, + "median": 0.919, + "p95": 1.079, + "p99": 1.079, + "max": 1.079, + "values": [ + 0.918, + 1.045, + 0.92, + 0.924, + 0.902, + 0.788, + 1.079, + 0.822 + ] + }, + "pretransfer_after_tls": { + "min": 0.01, + "mean": 0.017, + "median": 0.017, + "p95": 0.023, + "p99": 0.023, + "max": 0.023, + "values": [ + 0.022, + 0.02, + 0.023, + 0.018, + 0.015, + 0.015, + 0.01, + 0.016 + ] + }, + "response_tail_after_first_byte": { + "min": 0.008, + "mean": 0.01, + "median": 0.011, + "p95": 0.012, + "p99": 0.012, + "max": 0.012, + "values": [ + 0.012, + 0.011, + 0.011, + 0.011, + 0.011, + 0.009, + 0.008, + 0.01 + ] + }, + "server_first_byte_after_pretransfer": { + "min": 0.384, + "mean": 0.45, + "median": 0.446, + "p95": 0.51, + "p99": 0.51, + "max": 0.51, + "values": [ + 0.49, + 0.443, + 0.473, + 0.384, + 0.434, + 0.51, + 0.45, + 0.415 + ] + }, + "tcp_connect": { + "min": 0.027, + "mean": 0.077, + "median": 0.039, + "p95": 0.338, + "p99": 0.338, + "max": 0.338, + "values": [ + 0.04, + 0.338, + 0.035, + 0.037, + 0.053, + 0.027, + 0.033, + 0.052 + ] + }, + "tls_appconnect": { + "min": 1.448, + "mean": 1.73, + "median": 1.647, + "p95": 2.198, + "p99": 2.198, + "max": 2.198, + "values": [ + 2.198, + 1.75, + 1.632, + 2.055, + 1.663, + 1.448, + 1.539, + 1.556 + ] + } + }, + "keepalive_http_request_starttransfer_ms": { + "min": 0.315, + "mean": 0.364, + "median": 0.339, + "p95": 0.588, + "p99": 0.588, + "max": 0.588, + "values": [ + 0.588, + 0.339, + 0.315, + 0.315, + 0.339, + 0.338, + 0.344, + 0.331 + ] + }, + "keepalive_http_request_total_ms": { + "min": 0.321, + "mean": 0.37, + "median": 0.342, + "p95": 0.598, + "p99": 0.598, + "max": 0.598, + "values": [ + 0.598, + 0.343, + 0.324, + 0.321, + 0.344, + 0.342, + 0.35, + 0.335 + ] + }, + "keepalive_connection_ms": { + "connect_ms": 12.394, + "tls_handshake_ms": 1.155 + } + }, + "assertions": { + "session_db_security_events": { + "row_count": 17, + "distinct_event_ids": 17, + "blocked_count": 17, + "vm_id": "sechttp-b082fd18", + "profile_id": "profile-asset-boot", + "user_id": "elie", + "process_operation": null, + "process_command_class": null, + "rule_id": "runtime.block-http-bench.4be78026", + "reason": "HTTP request blocked by security benchmark" + }, + "runtime_match_count": 17, + "runtime_last_event_id": "net-http-6a748a70b8613fba", + "logs_exposed_security_decision": true + }, + "project_version": "1.2.1780103109", + "recorded_at": 1780149906.21412, + "recorded_at_utc": "2026-05-30T14:05:06.214122+00:00", + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": true, + "source_dirty": false, + "dirty_paths": [ + "benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json", + "benchmarks/capsem-bench/data_1.2.1780103109_arm64.json", + "benchmarks/fork/data_1.2.1780103109_arm64.json", + "benchmarks/host-native/data_1.2.1780103109_arm64.json", + "benchmarks/lifecycle/data_1.2.1780103109_arm64.json", + "benchmarks/parallel/data_1.2.1780103109_arm64.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_process_enforcement.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json" + ] + } +} diff --git a/benchmarks/security-engine/data_1.2.1780103109_arm64_mcp_request_enforcement.json b/benchmarks/security-engine/data_1.2.1780103109_arm64_mcp_request_enforcement.json new file mode 100644 index 00000000..4b01ef2d --- /dev/null +++ b/benchmarks/security-engine/data_1.2.1780103109_arm64_mcp_request_enforcement.json @@ -0,0 +1,106 @@ +{ + "schema": "capsem.security-engine-benchmark.v1", + "kind": "vm_originated_mcp_request_enforcement", + "version": "1.2.1780103109", + "source_commit": "0a425541", + "timestamp": 1780149910.48114, + "arch": "arm64", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "command": "uv run pytest tests/capsem-serial/test_security_engine_benchmark.py::test_mcp_request_enforcement_benchmark_records_vm_originated_path -xvs", + "workload": { + "event_family": "mcp", + "event_type": "mcp.request", + "source": "vm_originated", + "path": "guest_mcp_server_to_framed_vsock_to_security_engine" + }, + "runs": 8, + "gate_ms": 1000, + "rule": { + "id": "runtime.block-mcp-bench.22a9c133", + "pack_id": "runtime-benchmark", + "condition": "mcp.request.server_id == 'local' && mcp.request.tool_name == 'echo'", + "decision": "block" + }, + "operations": { + "blocked_mcp_request_ms": { + "min": 0.174, + "mean": 0.251, + "median": 0.189, + "p95": 0.661, + "p99": 0.661, + "max": 0.661, + "values": [ + 0.661, + 0.236, + 0.186, + 0.189, + 0.18, + 0.174, + 0.189, + 0.189 + ] + } + }, + "assertions": { + "session_db_security_events": { + "row_count": 8, + "distinct_event_ids": 8, + "blocked_count": 8, + "vm_id": "secmcp-2f6cb4ed", + "profile_id": "profile-asset-boot", + "user_id": "elie", + "process_operation": null, + "process_command_class": null, + "rule_id": "runtime.block-mcp-bench.22a9c133", + "reason": "MCP request blocked by security benchmark" + }, + "session_db_mcp_calls": { + "row_count": 8, + "denied_count": 8, + "server_name": "local", + "tool_name": "local__echo", + "policy_mode": "enforce", + "policy_action": "block", + "policy_rule": "runtime.block-mcp-bench.22a9c133", + "policy_reason": "MCP request blocked by security benchmark" + }, + "runtime_match_count": 8, + "runtime_last_event_id": "mcp-b93ff5c5aa69107e", + "logs_exposed_security_decision": true + }, + "project_version": "1.2.1780103109", + "recorded_at": 1780149910.48156, + "recorded_at_utc": "2026-05-30T14:05:10.481562+00:00", + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": true, + "source_dirty": false, + "dirty_paths": [ + "benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json", + "benchmarks/capsem-bench/data_1.2.1780103109_arm64.json", + "benchmarks/fork/data_1.2.1780103109_arm64.json", + "benchmarks/host-native/data_1.2.1780103109_arm64.json", + "benchmarks/lifecycle/data_1.2.1780103109_arm64.json", + "benchmarks/parallel/data_1.2.1780103109_arm64.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_dns_request_enforcement.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_http_request_enforcement.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_process_enforcement.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json" + ] + } +} diff --git a/benchmarks/security-engine/data_1.2.1780103109_arm64_process_enforcement.json b/benchmarks/security-engine/data_1.2.1780103109_arm64_process_enforcement.json new file mode 100644 index 00000000..cdb489a9 --- /dev/null +++ b/benchmarks/security-engine/data_1.2.1780103109_arm64_process_enforcement.json @@ -0,0 +1,93 @@ +{ + "schema": "capsem.security-engine-benchmark.v1", + "kind": "vm_originated_process_enforcement", + "version": "1.2.1780103109", + "source_commit": "0a425541", + "timestamp": 1780149903.954738, + "arch": "arm64", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "command": "uv run pytest tests/capsem-serial/test_security_engine_benchmark.py -xvs", + "workload": { + "event_family": "process", + "event_type": "process.exec", + "source": "vm_originated", + "path": "service_api_to_capsem_process_to_security_engine" + }, + "runs": 8, + "gate_ms": 750, + "rule": { + "id": "runtime.block-shell-bench.9a1332c1", + "pack_id": "runtime-benchmark", + "condition": "process.activity.operation == 'exec' && process.activity.command_class == 'shell'", + "decision": "block" + }, + "operations": { + "blocked_process_exec_ms": { + "min": 9.21, + "mean": 9.624, + "median": 9.618, + "p95": 9.937, + "p99": 9.937, + "max": 9.937, + "values": [ + 9.727, + 9.21, + 9.937, + 9.508, + 9.871, + 9.831, + 9.428, + 9.478 + ] + } + }, + "assertions": { + "session_db_security_events": { + "row_count": 8, + "distinct_event_ids": 8, + "blocked_count": 8, + "vm_id": "secbench-bd2f8976", + "profile_id": "profile-asset-boot", + "user_id": "elie", + "process_operation": "exec", + "process_command_class": "shell", + "rule_id": "runtime.block-shell-bench.9a1332c1", + "reason": "shell exec blocked by security benchmark" + }, + "runtime_match_count": 8, + "runtime_last_event_id": "process-a4e9824a05fed037", + "logs_exposed_security_decision": true + }, + "project_version": "1.2.1780103109", + "recorded_at": 1780149903.9552379, + "recorded_at_utc": "2026-05-30T14:05:03.955240+00:00", + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": true, + "source_dirty": false, + "dirty_paths": [ + "benchmarks/endpoint-latency/data_1.2.1780103109_arm64.json", + "benchmarks/capsem-bench/data_1.2.1780103109_arm64.json", + "benchmarks/fork/data_1.2.1780103109_arm64.json", + "benchmarks/host-native/data_1.2.1780103109_arm64.json", + "benchmarks/lifecycle/data_1.2.1780103109_arm64.json", + "benchmarks/parallel/data_1.2.1780103109_arm64.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json", + "benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json" + ] + } +} diff --git a/benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json b/benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json new file mode 100644 index 00000000..d580318b --- /dev/null +++ b/benchmarks/security-engine/data_1.2.1780103109_arm64_security_packs_microbench.json @@ -0,0 +1,170 @@ +{ + "schema": "capsem.security-engine-benchmark.v1", + "kind": "criterion_security_packs_microbench", + "source_commit": "0a425541", + "profile": { + "cargo_profile": "bench", + "criterion_samples": 100, + "criterion_warmup_seconds": 3, + "criterion_target_seconds": 5 + }, + "scope": { + "vm_originated": false, + "notes": [ + "Host-side microbenchmark only.", + "Measures Detection IR V1 JSON parse/validate, Detection IR to CEL detection-rule lowering, and lower-plus-compile costs.", + "Does not include VM transport, service IPC, runtime registry propagation, Security Engine dispatch, or session.db journal write latency." + ] + }, + "measurements": [ + { + "group": "security_packs_detection_ir_lowering", + "name": "lower_100_http_rules_to_cel_rules", + "full_id": "security_packs_detection_ir_lowering/lower_100_http_rules_to_cel_rules", + "estimate_kind": "slope", + "estimate_ns": 95640.41941628492, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 95045.14970432255, + "upper_bound": 96329.87718679853 + }, + "estimate_standard_error_ns": 327.56878700404326, + "mean_ns": 96446.10542150575, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 95853.13148354982, + "upper_bound": 97096.61599060171 + }, + "median_ns": 95467.98268272425, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 95114.03684210527, + "upper_bound": 95981.43939393939 + }, + "slope_ns": 95640.41941628492, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 95045.14970432255, + "upper_bound": 96329.87718679853 + }, + "slope_standard_error_ns": 327.56878700404326 + }, + { + "group": "security_packs_detection_ir_lowering", + "name": "lower_and_compile_100_http_rules", + "full_id": "security_packs_detection_ir_lowering/lower_and_compile_100_http_rules", + "estimate_kind": "mean", + "estimate_ns": 2725164.212777778, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 2705721.8505555554, + "upper_bound": 2745918.545555556 + }, + "estimate_standard_error_ns": 10264.84329494887, + "mean_ns": 2725164.212777778, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 2705721.8505555554, + "upper_bound": 2745918.545555556 + }, + "median_ns": 2692247.6944444445, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 2681824.0555555555, + "upper_bound": 2719081.0555555555 + } + }, + { + "group": "security_packs_detection_ir_lowering", + "name": "lower_google_secret_fixture_to_cel_rules", + "full_id": "security_packs_detection_ir_lowering/lower_google_secret_fixture_to_cel_rules", + "estimate_kind": "slope", + "estimate_ns": 1038.3981670183268, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1025.0317368968088, + "upper_bound": 1053.929184224994 + }, + "estimate_standard_error_ns": 7.382723794168417, + "mean_ns": 1076.374246032845, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1058.739251624613, + "upper_bound": 1096.4471027116813 + }, + "median_ns": 1065.9041099792303, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1045.7117690002306, + "upper_bound": 1076.2621004935872 + }, + "slope_ns": 1038.3981670183268, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 1025.0317368968088, + "upper_bound": 1053.929184224994 + }, + "slope_standard_error_ns": 7.382723794168417 + }, + { + "group": "security_packs_detection_ir_parse", + "name": "parse_validate_google_secret_fixture", + "full_id": "security_packs_detection_ir_parse/parse_validate_google_secret_fixture", + "estimate_kind": "slope", + "estimate_ns": 119488.19164277622, + "estimate_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 118802.76608230414, + "upper_bound": 120276.238150184 + }, + "estimate_standard_error_ns": 376.4519778173829, + "mean_ns": 121207.2188062783, + "mean_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 120533.83105893468, + "upper_bound": 121909.26397870253 + }, + "median_ns": 120325.47878592879, + "median_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 119782.95293565455, + "upper_bound": 120929.23898225957 + }, + "slope_ns": 119488.19164277622, + "slope_ci_ns": { + "confidence_level": 0.95, + "lower_bound": 118802.76608230414, + "upper_bound": 120276.238150184 + }, + "slope_standard_error_ns": 376.4519778173829 + } + ], + "project_version": "1.2.1780103109", + "arch": "arm64", + "recorded_at": 1780149809.041745, + "recorded_at_utc": "2026-05-30T14:03:29.041748+00:00", + "command": "cargo bench -p capsem-core --bench security_packs", + "host": { + "platform": "Darwin", + "release": "25.5.0", + "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:41:12 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T6050", + "machine": "arm64", + "processor": "arm", + "python_version": "3.14.4", + "cpu_count": 18, + "cpu_count_logical": 18, + "cpu_model": "Apple M5 Max", + "cpu_count_physical": 18, + "memory_total_bytes": 137438953472, + "os_product_version": "26.5", + "memory_total_gb": 128.0 + }, + "git": { + "commit": "0a425541fbdc03cc9821aafb238a0dd4b26ccdcd", + "dirty": true, + "source_dirty": false, + "dirty_paths": [ + "benchmarks/security-engine/data_1.2.1780103109_arm64_cel_microbench.json" + ] + } +} diff --git a/sprints/mac-benchmark-results/tracker.md b/sprints/mac-benchmark-results/tracker.md index f2ee3185..ce3ca6ed 100644 --- a/sprints/mac-benchmark-results/tracker.md +++ b/sprints/mac-benchmark-results/tracker.md @@ -5,7 +5,10 @@ - [x] Create branch from current `origin/main`. - [x] Run `just bench` on macOS. - [x] Fix blockers if the benchmark harness fails. -- [ ] Commit benchmark results. +- [x] Commit benchmark results. +- [x] Merge current `origin/main` Linux support into `codex/tui-control`. +- [x] Rerun canonical macOS benchmark after the Linux merge. +- [ ] Commit refreshed post-merge benchmark results. ## Notes @@ -29,13 +32,25 @@ `benchmarks/capsem-bench/data_1.2.1779673506_arm64.json`. - No Linux benchmark JSON is present in this checkout or visible remote branch names; comparison needs the Linux team's JSON artifact or branch. +- Follow-up on `codex/tui-control`: merged `origin/main` at `62b5dfe8`, which + brings the Linux support and benchmark retention/comparison work. +- First post-merge macOS benchmark attempt from `/private/tmp/capsem-tui-control` + failed during asset prep because Colima only exposed `target/` inside the + Docker `/src` bind mount. Moved the worktree to + `/Users/elie/git/capsem-tui-control` so Docker can see the full checkout. +- `CAPSEM_HOME=$PWD/target/bench-home CAPSEM_RUN_DIR=$PWD/target/bench-home/run just benchmark` + passed on macOS arm64 after the merge: 10 selected serial benchmark tests + passed, Criterion artifacts archived, and old arm64 artifacts were retired. +- `just benchmark-compare` now has both Linux x86_64 and macOS arm64 artifacts; + current Linux results remain materially slower than macOS across scratch I/O, + rootfs, startup, lifecycle, fork, and security lanes. ## Coverage Ledger - Unit/contract: `uv run python -m pytest tests/test_build_assets_script.py::test_ensure_service_refreshes_local_profile_after_asset_repack -q` -- Functional: `CAPSEM_HOME=$PWD/target/bench-home CAPSEM_RUN_DIR=$PWD/target/bench-home/run just bench` +- Functional: `CAPSEM_HOME=$PWD/target/bench-home CAPSEM_RUN_DIR=$PWD/target/bench-home/run just bench`; post-merge rerun used `just benchmark`. - Adversarial: not needed unless code changes. - E2E/VM: in-VM `capsem-bench` and host lifecycle/fork benchmarks passed via `just bench`. - Telemetry: not claimed. -- Performance: new JSON under `benchmarks/capsem-bench/`, `benchmarks/lifecycle/`, `benchmarks/fork/`, and `benchmarks/security-engine/`. -- Missing/deferred: Linux comparison artifact is not present locally. +- Performance: new JSON under `benchmarks/capsem-bench/`, `benchmarks/endpoint-latency/`, `benchmarks/host-native/`, `benchmarks/lifecycle/`, `benchmarks/fork/`, `benchmarks/parallel/`, and `benchmarks/security-engine/`. +- Missing/deferred: none for the macOS rerun; Linux/macOS comparison is now available through `just benchmark-compare`. From 860cc8ea39ffe2b795ff59f699ff44d2203e1d97 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 10:17:36 -0400 Subject: [PATCH 31/35] feat: make capsem shell launch tui --- CHANGELOG.md | 3 + crates/capsem-tui/src/app.rs | 13 + crates/capsem-tui/src/main.rs | 32 +- crates/capsem-tui/src/tests.rs | 14 + crates/capsem/src/main.rs | 293 +++----------- crates/capsem/src/shell_exit.rs | 45 --- crates/capsem/src/shell_exit/tests.rs | 356 ------------------ .../content/docs/development/just-recipes.md | 4 +- docs/src/content/docs/getting-started.md | 6 +- docs/src/content/docs/usage/cli.md | 11 +- justfile | 6 +- sprints/tui-control/MASTER.md | 10 +- sprints/tui-control/tracker.md | 11 + 13 files changed, 137 insertions(+), 667 deletions(-) delete mode 100644 crates/capsem/src/shell_exit.rs delete mode 100644 crates/capsem/src/shell_exit/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a28fb12..3daebd77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 modal directly and brands it with a compact gradient CAPSEM wordmark. - Changed the `capsem-tui` status hint to `help: alt+?` and moved it to the far right after active-session statistics, including the empty-session state. +- Changed `capsem shell` to launch `capsem-tui` as the single interactive VM + control surface; `capsem shell ` now opens the TUI focused on that + session instead of using the legacy direct PTY bridge. - Added Linux KVM doctor coverage that creates and resolves symlinks under `/tmp`, keeping link-heavy cache/tool probes off the VirtioFS workspace while leaving snapshot symlink restore scoped to `/root`. diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index 339b6b2d..2691d7ff 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -231,6 +231,19 @@ impl App { self.sync_active_session(); } + pub fn select_session_by_id(&mut self, id: &str) -> bool { + let Some(index) = self + .state + .sessions + .iter() + .position(|session| session.id == id || session.title == id) + else { + return false; + }; + self.select_session(index); + true + } + fn sync_active_session(&mut self) { let Some(session) = self.state.sessions.get(self.active_index) else { return; diff --git a/crates/capsem-tui/src/main.rs b/crates/capsem-tui/src/main.rs index 8103c558..ac2c6e09 100644 --- a/crates/capsem-tui/src/main.rs +++ b/crates/capsem-tui/src/main.rs @@ -45,6 +45,10 @@ struct Cli { #[arg(long, default_value_t = 1_000)] refresh_ms: u64, + /// Start focused on a specific session id or title. + #[arg(long)] + session: Option, + /// Snapshot width. #[arg(long, default_value_t = 100)] width: u16, @@ -57,20 +61,15 @@ struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); let state = load_state(&cli)?; + let app = app_from_state(state, cli.session.as_deref())?; if cli.snapshot_svg { - println!( - "{}", - render_app_svg_snapshot(&App::new(state), cli.width, cli.height)? - ); + println!("{}", render_app_svg_snapshot(&app, cli.width, cli.height)?); return Ok(()); } if cli.snapshot { - println!( - "{}", - render_app_snapshot(&App::new(state), cli.width, cli.height)? - ); + println!("{}", render_app_snapshot(&app, cli.width, cli.height)?); return Ok(()); } @@ -79,12 +78,7 @@ fn main() -> Result<()> { .as_ref() .map(|provider| TerminalBridge::spawn(provider.base_url().to_string())); - run_interactive( - App::new(state), - live_provider, - terminal_bridge, - cli.refresh_interval(), - ) + run_interactive(app, live_provider, terminal_bridge, cli.refresh_interval()) } fn load_state(cli: &Cli) -> Result { @@ -105,6 +99,16 @@ fn load_state(cli: &Cli) -> Result { } } +fn app_from_state(state: AppState, session: Option<&str>) -> Result { + let mut app = App::new(state); + if let Some(session) = session { + if !app.select_session_by_id(session) { + anyhow::bail!("session not found in TUI state: {session}"); + } + } + Ok(app) +} + fn live_provider(cli: &Cli) -> Option { if cli.fixture { return None; diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index e8e5bbc8..0eae048d 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -263,6 +263,20 @@ fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { ); } +#[test] +fn app_can_start_focused_on_session_id_or_title() { + let mut app = App::new(fixture_state()); + + assert!(app.select_session_by_id("linux-os")); + assert_eq!(app.state().active_session_id, "linux-os"); + + assert!(app.select_session_by_id("Profile V2")); + assert_eq!(app.state().active_session_id, "profile-v2"); + + assert!(!app.select_session_by_id("missing-session")); + assert_eq!(app.state().active_session_id, "profile-v2"); +} + #[test] fn replace_state_preserves_fresh_service_latency_measurement() { let mut initial = fixture_state(); diff --git a/crates/capsem/src/main.rs b/crates/capsem/src/main.rs index 9ec4ebdf..33a119e9 100644 --- a/crates/capsem/src/main.rs +++ b/crates/capsem/src/main.rs @@ -5,7 +5,6 @@ mod platform; mod profile_catalog_source; mod service_install; mod setup; -mod shell_exit; mod status; mod support; mod support_bundle; @@ -17,7 +16,7 @@ use clap::builder::styling::{AnsiColor, Color, Style, Styles}; use clap::{Parser, Subcommand, ValueEnum}; use std::fmt::Write as _; use std::path::{Path, PathBuf}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::AsyncWriteExt; use client::{ ApiResponse, ExecRequest, ExecResponse, ForkRequest, ForkResponse, HistoryResponse, @@ -57,7 +56,7 @@ const fn cli_styles() -> Styles { const GROUPED_HELP: &str = "\ \x1b[36;1;4mSession Commands:\x1b[0m \x1b[32;1mcreate\x1b[0m Create and boot a new session - \x1b[32;1mshell\x1b[0m Open an interactive shell in a session + \x1b[32;1mshell\x1b[0m Open the Capsem TUI \x1b[32;1mresume\x1b[0m Resume a suspended session or attach to a running one \x1b[32;1msuspend\x1b[0m Suspend a running session to disk \x1b[32;1mrestart\x1b[0m Restart a persistent session (reboot) @@ -963,10 +962,10 @@ enum SessionCommands { #[arg(long = "profile-revision")] profile_revision: Option, }, - /// Open an interactive shell in a session + /// Open the Capsem TUI /// - /// With no arguments, creates a temporary session (destroyed on exit). - /// Pass a session name/ID to attach to an existing running session. + /// With no arguments, opens the TUI home/create flow. + /// Pass a session name/ID to focus the TUI on that session. Shell { /// Name or ID of the session (positional) #[arg(value_name = "SESSION")] @@ -2036,193 +2035,43 @@ fn print_session_info(info: &SessionInfo) { } } -async fn run_shell(id: &str, run_dir: &std::path::Path) -> Result<()> { - use capsem_proto::ipc::{ProcessToService, ServiceToProcess}; - use nix::sys::termios::{tcgetattr, tcsetattr, SetArg}; - use std::sync::Arc; - use tokio_unix_ipc::{channel_from_std, Receiver, Sender}; - - client::validate_id(id)?; - let sock_path = run_dir.join("instances").join(format!("{}.sock", id)); - if !sock_path.exists() { - anyhow::bail!("Session socket not found at: {}", sock_path.display()); - } - - let stream = tokio::net::UnixStream::connect(&sock_path) - .await - .context("failed to connect to VM session")?; - let mut std_stream = stream.into_std()?; - capsem_core::ipc_handshake::negotiate_initiator( - &mut std_stream, - "capsem-cli", - capsem_core::telemetry::current_parent_traceparent(), - ) - .context("IPC handshake failed")?; - #[allow(unused_variables)] - let (tx, rx): (Sender, Receiver) = - channel_from_std(std_stream)?; - let tx = Arc::new(tx); - - // Request terminal streaming - tx.send(ServiceToProcess::StartTerminalStream).await?; - - use std::os::unix::io::{AsRawFd, BorrowedFd}; - - let stdin_fd = std::io::stdin().as_raw_fd(); - let is_tty = nix::unistd::isatty(stdin_fd).unwrap_or(false); - - let get_terminal_size = || -> Option<(u16, u16)> { - let mut ws: nix::libc::winsize = unsafe { std::mem::zeroed() }; - if unsafe { nix::libc::ioctl(stdin_fd, nix::libc::TIOCGWINSZ, &mut ws) } == 0 { - Some((ws.ws_col, ws.ws_row)) - } else { - None - } - }; - - // Send initial window size - if is_tty { - if let Some((cols, rows)) = get_terminal_size() { - capsem_core::try_send!( - "cli_terminal_resize_init", - tx.send(ServiceToProcess::TerminalResize { cols, rows }) - .await - ); - } - } +fn capsem_shell_tui_args(session: Option<&str>) -> Vec { + session + .map(|session| vec!["--session".to_string(), session.to_string()]) + .unwrap_or_default() +} - struct RawModeGuard { - fd: std::os::unix::io::RawFd, - original: Option, +fn resolve_capsem_tui_binary() -> PathBuf { + if let Ok(path) = std::env::var("CAPSEM_SHELL_TUI_BINARY") { + return PathBuf::from(path); } - impl Drop for RawModeGuard { - fn drop(&mut self) { - if let Some(ref original) = self.original { - let borrowed = unsafe { std::os::unix::io::BorrowedFd::borrow_raw(self.fd) }; - let _ = tcsetattr(borrowed, SetArg::TCSANOW, original); + if let Ok(current_exe) = std::env::current_exe() { + if let Some(parent) = current_exe.parent() { + let sibling = parent.join("capsem-tui"); + if sibling.exists() { + return sibling; } } } + PathBuf::from("capsem-tui") +} - let original_termios = if is_tty { - let borrowed_fd = unsafe { BorrowedFd::borrow_raw(stdin_fd) }; - let orig = tcgetattr(borrowed_fd).ok(); - if let Some(ref o) = orig { - let mut raw_termios = o.clone(); - nix::sys::termios::cfmakeraw(&mut raw_termios); - let _ = tcsetattr(borrowed_fd, SetArg::TCSANOW, &raw_termios); - } - orig - } else { - None - }; - - let _guard = RawModeGuard { - fd: stdin_fd, - original: original_termios, - }; - - let mut stdin = tokio::io::stdin(); - let mut buf = vec![0u8; 65536]; - - // Spawn a task to read from IPC and write to stdout - let mut output_task = tokio::spawn(async move { - let mut stdout = tokio::io::stdout(); - while let Ok(msg) = rx.recv().await { - match msg { - ProcessToService::TerminalOutput { data } => { - // Smoking-gun trace mirrored from capsem-process. If a - // payload prefix looks like an IPC frame, dump the - // first 16 bytes to stderr (visible to the user, also - // capturable via `capsem shell 2>shell.log`). Catches - // the leak even when process.log isn't being tailed. - if shell_exit::looks_like_msgpack_ipc_frame(&data) { - let preview: Vec = - data.iter().take(16).map(|b| format!("{:02x}", b)).collect(); - eprintln!( - "\r\n[capsem-shell] WARN: PTY stream starts with IPC-frame-shaped bytes \ - (len={}, first16={})\r", - data.len(), - preview.join(" "), - ); - } - let _ = stdout.write_all(&data).await; - let _ = stdout.flush().await; - } - ProcessToService::Pong => {} - ProcessToService::ReloadConfigResult { .. } => {} - ProcessToService::StateChanged { .. } => {} - ProcessToService::ExecResult { .. } => {} - ProcessToService::WriteFileResult { .. } => {} - ProcessToService::ReadFileResult { .. } => {} - ProcessToService::ShutdownRequested { .. } - | ProcessToService::SuspendRequested { .. } - | ProcessToService::SnapshotReady { .. } - | ProcessToService::MetricsSnapshot { .. } - | ProcessToService::RuntimeRuleMatches { .. } => {} - } - } - }); - - let mut sigwinch = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::window_change())?; - - // Read from stdin and send over IPC. - // Also watch for output_task completion (VM connection closed). - loop { - tokio::select! { - _ = sigwinch.recv() => { - if is_tty { - if let Some((cols, rows)) = get_terminal_size() { - capsem_core::try_send!("cli_terminal_resize", tx.send(ServiceToProcess::TerminalResize { cols, rows }).await); - } - } - } - _ = &mut output_task => { - // VM connection closed (shutdown, process exit, etc.) - break; - } - res = stdin.read(&mut buf) => { - match res { - Ok(0) => break, // EOF - Ok(n) => { - // Exit on Ctrl+D (0x04) explicitly if needed, but since we map raw input, - // usually we let the guest handle Ctrl+D. For a clean local exit, we can - // trap Ctrl+] (0x1D) as the disconnect signal. - if n == 1 && buf[0] == 0x1D { - break; - } - capsem_core::try_send!("cli_terminal_input", tx.send(ServiceToProcess::TerminalInput { data: buf[..n].to_vec() }).await); - } - Err(_) => break, - } - } - } +async fn run_tui_shell(session: Option<&str>) -> Result<()> { + if let Some(session) = session { + client::validate_id(session)?; + } + let binary = resolve_capsem_tui_binary(); + let status = tokio::process::Command::new(&binary) + .args(capsem_shell_tui_args(session)) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .await + .with_context(|| format!("launch {}", binary.display()))?; + if !status.success() { + anyhow::bail!("{} exited with {}", binary.display(), status); } - - // ---- Clean shell exit ---- - // Order matters and is asserted by tests in shell_exit::tests: - // 1. Tell the host to stop streaming so no new TerminalOutput frames - // get queued for this connection. - // 2. Abort the local output task. tokio JoinHandle drop does NOT - // cancel; without abort the task lives on, holds stdout, and any - // in-flight TerminalOutput frame will write to the user's parent - // shell after raw mode is restored. This is the symptom that - // manifested as "MessagePack-shaped garbage in my terminal after - // `capsem shell`". - // 3. Drop tx to close the IPC writer half (defensive; the next read - // loop will hit ECONNRESET and the connection winds down cleanly). - // 4. Reset the terminal: SGR reset + show cursor + move to col 0. - // RawModeGuard restores termios on Drop right after this, but - // in-flight escape sequences from the guest can leave the terminal - // in a weird state (alt screen, scroll region, cursor hidden). - capsem_core::try_send!( - "cli_stop_terminal_stream", - tx.send(ServiceToProcess::StopTerminalStream).await - ); - output_task.abort(); - drop(tx); - shell_exit::reset_user_terminal(is_tty).await; Ok(()) } @@ -2705,6 +2554,11 @@ async fn main() -> Result<()> { } } + if let Commands::Session(SessionCommands::Shell { session }) = cli.command.as_ref().unwrap() { + run_tui_shell(session.as_deref()).await?; + return Ok(()); + } + let client = UdsClient::new(uds_path, auto_launch); match cli.command.as_ref().unwrap() { @@ -2776,57 +2630,7 @@ async fn main() -> Result<()> { resp.into_result()?; println!("Session suspended."); } - Commands::Session(SessionCommands::Shell { session }) => { - match session { - Some(t) => { - client::validate_id(t.as_str())?; - run_shell(t.as_str(), &run_dir).await?; - } - None => { - // No args: create ephemeral session, attach, destroy on exit - println!("[!] Temporary session. Use `capsem create ` for persistent."); - let req = ProvisionRequest { - name: None, - ram_mb: 4 * 1024, - cpus: 4, - persistent: false, - env: None, - from: None, - profile_id: None, - profile_revision: None, - }; - let resp: ApiResponse = - client.post("/provision", &req).await?; - let info = resp.into_result()?; - - // Poll until the socket is connectable (not just present on disk). - let socket_path = run_dir.join("instances").join(format!("{}.sock", info.id)); - let sp = socket_path.clone(); - let _ = capsem_core::poll::poll_until( - capsem_core::poll::PollOpts::new( - "shell-socket", - std::time::Duration::from_secs(10), - ), - || { - let sp = sp.clone(); - async move { - match tokio::net::UnixStream::connect(&sp).await { - Ok(_) => Some(()), - Err(_) => None, - } - } - }, - ) - .await; - - let shell_result = run_shell(&info.id, &run_dir).await; - // Ephemeral: auto-destroy on disconnect - let _: Result, _> = - client.delete(&format!("/delete/{}", info.id)).await; - shell_result?; - } - } - } + Commands::Session(SessionCommands::Shell { .. }) => unreachable!("handled before client"), Commands::Session(SessionCommands::List { quiet }) => { let resp: ApiResponse = client.get("/list").await?; let resp = resp.into_result()?; @@ -4393,7 +4197,7 @@ mod tests { #[test] fn parse_shell_bare() { - // Bare `capsem shell` = temp session + auto-destroy + // Bare `capsem shell` = TUI home/create flow. let cli = Cli::parse_from(["capsem", "shell"]); match cli.command.unwrap() { Commands::Session(SessionCommands::Shell { session }) => { @@ -4403,6 +4207,19 @@ mod tests { } } + #[test] + fn shell_without_session_launches_tui_without_args() { + assert_eq!(capsem_shell_tui_args(None), Vec::::new()); + } + + #[test] + fn shell_session_maps_to_tui_session_arg() { + assert_eq!( + capsem_shell_tui_args(Some("my-vm")), + vec!["--session".to_string(), "my-vm".to_string()] + ); + } + #[test] fn parse_persist() { let cli = Cli::parse_from(["capsem", "persist", "vm-123", "mydev"]); diff --git a/crates/capsem/src/shell_exit.rs b/crates/capsem/src/shell_exit.rs deleted file mode 100644 index e1482f8c..00000000 --- a/crates/capsem/src/shell_exit.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Shell-exit cleanup helpers. -//! -//! Extracted so the contract can be unit-tested without standing up a real -//! VM or IPC channel. See `tests.rs` for the invariants this module is -//! pinning -- in short, "what `capsem shell` writes to the user's terminal -//! after the loop exits, and what it does NOT". - -use tokio::io::AsyncWriteExt; - -/// Bytes we write to stdout right before letting the `RawModeGuard` in -/// `run_shell` restore termios. -/// -/// - `\x1b[0m` -- SGR reset (clear bold/colors/inverse). Without this a -/// guest that ended mid-color paints the parent shell prompt the wrong color. -/// - `\x1b[?25h` -- show cursor. Guests sometimes hide it (e.g. fullscreen -/// TUIs) and crash before showing it again. -/// - `\r\n` -- explicit CRLF so the next prompt starts at column 0 -/// even if the guest left the cursor mid-line. -/// -/// Deliberately does NOT include alt-screen toggles or screen clears -- -/// those would erase the user's scrollback. See `tests.rs` for the guard -/// rails that keep accidental additions out. -pub const TERMINAL_RESET_SEQUENCE: &[u8] = b"\x1b[0m\x1b[?25h\r\n"; - -/// Write the reset sequence to the user's stdout (only when on a tty; -/// on a pipe or file, escape codes would just clutter the output). -/// -/// Best-effort: errors are swallowed because by the time we hit this path -/// we're already exiting and there is nothing useful to do with a failure. -pub async fn reset_user_terminal(is_tty: bool) { - if !is_tty { - return; - } - let mut stdout = tokio::io::stdout(); - let _ = stdout.write_all(TERMINAL_RESET_SEQUENCE).await; - let _ = stdout.flush().await; -} - -/// Re-export of the canonical detector in `capsem_proto`. Kept under the -/// `shell_exit` namespace because that's the consumer the tests cover and -/// the documentation comments are co-located. -pub use capsem_proto::looks_like_ipc_frame as looks_like_msgpack_ipc_frame; - -#[cfg(test)] -mod tests; diff --git a/crates/capsem/src/shell_exit/tests.rs b/crates/capsem/src/shell_exit/tests.rs deleted file mode 100644 index 41be37ef..00000000 --- a/crates/capsem/src/shell_exit/tests.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! Tests pinning the `capsem shell` exit invariants. -//! -//! Background: a user reported that pressing Ctrl-C / typing `exit` in -//! `capsem shell` left their terminal flooded with binary garbage -//! (MessagePack frames -- `bootconfig`, `epoch_secs`, `Pong` repeated). -//! Symptoms came from two compounding bugs: -//! 1. The `output_task` spawned by `run_shell` was never aborted. -//! tokio's `JoinHandle` drop does NOT cancel the task -- it lives -//! on the runtime, holds `stdout`, and any TerminalOutput frame -//! that arrives after the loop exits writes to the user's now- -//! cooked-mode parent shell. -//! 2. The host kept queuing `ProcessToService::TerminalOutput` frames -//! because the client never told it "I'm gone, stop streaming". -//! -//! These tests pin the contract. - -#![allow(clippy::needless_pass_by_value)] - -use super::*; - -// --------------------------------------------------------------------------- -// 1. Reset sequence shape. -// --------------------------------------------------------------------------- - -#[test] -fn reset_sequence_clears_sgr_and_shows_cursor() { - let s = std::str::from_utf8(TERMINAL_RESET_SEQUENCE) - .expect("reset sequence must be valid utf-8 (it is just ANSI escapes + CRLF)"); - // SGR reset (clears bold/color/inverse). Without this a guest that - // ended mid-color paints the parent shell prompt the wrong color. - assert!( - s.contains("\x1b[0m"), - "reset must contain SGR reset; got {:?}", - s - ); - // Show cursor (guests sometimes hide it and crash before showing). - assert!( - s.contains("\x1b[?25h"), - "reset must contain show-cursor; got {:?}", - s - ); - // CRLF so the next prompt starts at column 0 regardless of where - // the guest left the cursor. - assert!(s.ends_with("\r\n"), "reset must end with CRLF; got {:?}", s); -} - -#[test] -fn reset_sequence_contains_no_alternate_screen_toggle() { - // Switching screens in the cleanup would WIPE the user's scrollback - // every time they exit a sandbox shell. Guard against accidentally - // adding `\x1b[?1049l` (alt-screen exit) here. - let s = std::str::from_utf8(TERMINAL_RESET_SEQUENCE).unwrap(); - assert!( - !s.contains("\x1b[?1049"), - "must not toggle alt-screen on exit" - ); - assert!( - !s.contains("\x1b[?47"), - "must not toggle alt-screen on exit (legacy)" - ); -} - -#[test] -fn reset_sequence_contains_no_clear_screen() { - // `\x1b[2J` would erase the visible scrollback. The user is exiting - // a sandbox; they want to KEEP what they ran before. - let s = std::str::from_utf8(TERMINAL_RESET_SEQUENCE).unwrap(); - assert!(!s.contains("\x1b[2J"), "must not clear screen on exit"); - assert!( - !s.contains("\x1bc"), - "must not full-reset (RIS) on exit -- clears scrollback" - ); -} - -#[test] -fn reset_sequence_is_short() { - // Belt and braces: a runaway reset sequence (e.g. someone added a - // big clear) should fail loudly. 32 bytes is plenty for the legitimate - // SGR + show-cursor + CRLF combo (~9 bytes). - assert!( - TERMINAL_RESET_SEQUENCE.len() <= 32, - "reset sequence is {} bytes; expected <= 32 (something got added)", - TERMINAL_RESET_SEQUENCE.len(), - ); -} - -// --------------------------------------------------------------------------- -// 2. tty-vs-pipe behavior. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn reset_user_terminal_is_noop_when_not_a_tty() { - // When stdout is a pipe (CI, `capsem shell | tee ...`), writing ANSI - // escapes pollutes the captured output. is_tty=false must short-circuit. - // - // We can't easily intercept the global stdout in a unit test, but we - // can at least assert the function returns quickly and doesn't panic. - let start = std::time::Instant::now(); - reset_user_terminal(false).await; - assert!(start.elapsed() < std::time::Duration::from_millis(50)); -} - -#[tokio::test] -async fn reset_user_terminal_does_not_panic_when_tty_unavailable() { - // Even with is_tty=true, stdout might fail to write (closed pipe, - // EPIPE under SIGPIPE-ignore). Exit cleanup must never panic. - reset_user_terminal(true).await; -} - -// --------------------------------------------------------------------------- -// 3. tokio JoinHandle abort semantics -- the load-bearing fix. -// --------------------------------------------------------------------------- -// -// The original bug was: `let mut output_task = tokio::spawn(...)`, then -// the function returned without calling `.abort()`. The task kept running -// (drop of JoinHandle does NOT cancel) and continued to write to stdout. -// These tests pin the abort behavior we rely on. - -#[tokio::test] -async fn join_handle_drop_does_not_cancel_task() { - // This is what BIT US. JoinHandle::drop() detaches; it does NOT abort. - // If this assertion ever flips (e.g. tokio changes behavior), the - // band-aid in run_shell is unnecessary and we can simplify. - let started = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let s = started.clone(); - let h = tokio::spawn(async move { - s.store(true, std::sync::atomic::Ordering::SeqCst); - loop { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - }); - drop(h); // <- explicit drop, mirrors run_shell return path - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - assert!( - started.load(std::sync::atomic::Ordering::SeqCst), - "task should have started despite JoinHandle drop" - ); - // We can't easily assert "still running" without holding a handle, - // but the lack of a panic from runtime shutdown proves it didn't get - // implicitly cancelled. -} - -#[tokio::test] -async fn join_handle_abort_actually_stops_the_task() { - let counter = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); - let c = counter.clone(); - let h = tokio::spawn(async move { - loop { - c.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - } - }); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - h.abort(); - let snapshot = counter.load(std::sync::atomic::Ordering::SeqCst); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let after = counter.load(std::sync::atomic::Ordering::SeqCst); - // After abort, the counter must stop incrementing. Allow +1 for an - // already-scheduled iteration that ran between abort() and the snapshot. - assert!( - after <= snapshot + 1, - "task should be stopped after abort: snapshot={snapshot} after={after}" - ); -} - -// --------------------------------------------------------------------------- -// 4. Regression detector: anything that LOOKS like MessagePack must not -// appear in TerminalOutput data. -// --------------------------------------------------------------------------- -// -// HostToGuest / GuestToHost frames are encoded via `rmp_serde::to_vec_named` -// with `#[serde(tag = "t", content = "d", rename_all = "lowercase")]`. Every -// such frame begins with the bytes `0x82 0xa1 't' 0xa?` (fixmap[2], fixstr[1] -// "t", fixstr[N] ""). If a TerminalOutput.data buffer ever carries -// that prefix, an IPC frame leaked into the PTY stream -- exactly the bug -// this whole module exists to prevent. - -// Detector lives in `super` (shell_exit.rs) so production code can also -// use it for smoking-gun logging if the leak ever resurfaces. - -#[test] -fn detector_recognizes_real_bootconfig_frame() { - use capsem_proto::HostToGuest; - let bytes = capsem_proto::encode_host_msg(&HostToGuest::BootConfig { - epoch_secs: 1234, - traceparent: String::new(), - }) - .expect("encode"); - // Strip the 4-byte length prefix that encode_host_msg adds. - let payload = &bytes[4..]; - assert!( - looks_like_msgpack_ipc_frame(payload), - "detector should match real BootConfig frame, payload={payload:02x?}" - ); -} - -#[test] -fn detector_recognizes_real_pong_frame() { - use capsem_proto::GuestToHost; - let bytes = capsem_proto::encode_guest_msg(&GuestToHost::Pong).expect("encode"); - let payload = &bytes[4..]; - assert!( - looks_like_msgpack_ipc_frame(payload), - "detector should match real Pong frame, payload={payload:02x?}" - ); -} - -#[test] -fn detector_recognizes_real_setenv_frame() { - use capsem_proto::HostToGuest; - let bytes = capsem_proto::encode_host_msg(&HostToGuest::SetEnv { - key: "FOO".into(), - value: "bar".into(), - }) - .expect("encode"); - let payload = &bytes[4..]; - assert!(looks_like_msgpack_ipc_frame(payload)); -} - -#[test] -fn detector_does_not_false_positive_on_normal_terminal_output() { - // ANSI escape from a guest that just ran `ls --color`. - let ansi = b"\x1b[01;34mdir\x1b[0m\r\n"; - assert!(!looks_like_msgpack_ipc_frame(ansi)); - - // Plain ASCII bash prompt. - let prompt = b"capsem@vm:~$ "; - assert!(!looks_like_msgpack_ipc_frame(prompt)); - - // Bash 'exit' echo + newline -- the exact bytes the user sees right - // before garbage in the original report. - assert!(!looks_like_msgpack_ipc_frame(b"exit\r\n")); - - // A short prefix that's too small to be a frame. - assert!(!looks_like_msgpack_ipc_frame(b"")); - assert!(!looks_like_msgpack_ipc_frame(b"\x82")); - assert!(!looks_like_msgpack_ipc_frame(b"\x82\xa1")); - assert!(!looks_like_msgpack_ipc_frame(b"\x82\xa1t")); - assert!(!looks_like_msgpack_ipc_frame(b"\x81")); - assert!(!looks_like_msgpack_ipc_frame(b"\x81\xa1t")); - - // Nearly-matching bytes that are NOT an IPC frame. - assert!(!looks_like_msgpack_ipc_frame(b"\x82\xa1x\xaa")); // wrong tag char - assert!(!looks_like_msgpack_ipc_frame(b"\x80\xa1t\xaa")); // fixmap[0] - assert!(!looks_like_msgpack_ipc_frame(b"\x83\xa1t\xaa")); // fixmap[3] - assert!(!looks_like_msgpack_ipc_frame(b"\x82\xa2tt\xaa")); // fixstr[2] for the key - - // UTF-8 text that happens to contain 0x82 byte mid-stream is fine. - let utf = "héllo wörld\n".as_bytes(); - assert!(!looks_like_msgpack_ipc_frame(utf)); -} - -#[test] -fn detector_does_not_false_positive_on_msgpack_inside_data() { - // The real bug is leakage at the START of a TerminalOutput.data buffer - // (capsem-shell writes data verbatim). MessagePack bytes appearing - // INSIDE legitimate file content (e.g. `cat msgpack-blob.bin`) are - // not a leak -- they're what the user asked for. Detector targets - // the start-of-buffer case only. - let mixed = { - let mut v = b"hello ".to_vec(); - v.extend_from_slice(b"\x82\xa1t\xaa\xaa"); - v - }; - assert!(!looks_like_msgpack_ipc_frame(&mixed)); -} - -// --------------------------------------------------------------------------- -// 5. Catalog: every variant of every IPC envelope produces a frame the -// detector can recognize. If a future variant is added with a different -// serde tag scheme, this test fails and we know the detector needs an -// update before the leak can resurface unnoticed. -// --------------------------------------------------------------------------- - -#[test] -fn detector_recognizes_every_host_to_guest_variant() { - use capsem_proto::HostToGuest; - let samples = [ - HostToGuest::BootConfig { - epoch_secs: 1, - traceparent: String::new(), - }, - HostToGuest::SetEnv { - key: "K".into(), - value: "V".into(), - }, - HostToGuest::FileWrite { - id: 1, - path: "/p".into(), - data: vec![], - mode: 0o644, - }, - HostToGuest::FileRead { - id: 1, - path: "/p".into(), - }, - HostToGuest::FileDelete { - id: 1, - path: "/p".into(), - }, - HostToGuest::BootConfigDone, - HostToGuest::Resize { cols: 80, rows: 24 }, - HostToGuest::Ping { epoch_secs: 0 }, - HostToGuest::Shutdown, - HostToGuest::Exec { - id: 1, - command: "ls".into(), - }, - HostToGuest::PrepareSnapshot, - ]; - for msg in samples { - let bytes = capsem_proto::encode_host_msg(&msg).expect("encode"); - let payload = &bytes[4..]; // strip 4-byte length prefix - assert!( - looks_like_msgpack_ipc_frame(payload), - "detector missed HostToGuest variant {:?} -- payload={:02x?}", - msg, - payload, - ); - } -} - -#[test] -fn detector_recognizes_every_guest_to_host_variant() { - use capsem_proto::GuestToHost; - let samples = [ - GuestToHost::Pong, - GuestToHost::Ready { - version: "1.0".into(), - }, - GuestToHost::Error { - id: 1, - message: "x".into(), - }, - GuestToHost::FileOpDone { id: 1 }, - GuestToHost::FileContent { - id: 1, - path: "/p".into(), - data: vec![], - }, - GuestToHost::ExecDone { - id: 1, - exit_code: 0, - }, - ]; - for msg in samples { - let bytes = capsem_proto::encode_guest_msg(&msg).expect("encode"); - let payload = &bytes[4..]; - assert!( - looks_like_msgpack_ipc_frame(payload), - "detector missed GuestToHost variant {:?} -- payload={:02x?}", - msg, - payload, - ); - } -} diff --git a/docs/src/content/docs/development/just-recipes.md b/docs/src/content/docs/development/just-recipes.md index 776efd47..39568068 100644 --- a/docs/src/content/docs/development/just-recipes.md +++ b/docs/src/content/docs/development/just-recipes.md @@ -11,14 +11,14 @@ sidebar: | Recipe | What it does | Time | |--------|-------------|------| -| `just shell` | Build/sign as needed, boot a temporary VM, and attach a shell | ~10s after first build | +| `just shell` | Build/sign as needed, start or reuse the service, and open the TUI | ~10s after first build | | `just exec "CMD"` | Run a command in a fresh temporary VM, then destroy it | ~10s after first build | | `just run-service` | Start or reuse the daemon service | continuous | | `just ui` | Tauri desktop app with hot reload and the service path | continuous | | `just dev-frontend` | Frontend-only dev server with mock data on port 5173 | continuous | | `just build-ui [release]` | Frontend build plus `cargo build -p capsem-app` | build dependent | -`just shell` is the daily VM driver. `just exec "CMD"` is the one-shot path for +`just shell` is the daily TUI driver. `just exec "CMD"` is the one-shot path for quick checks. After frontend changes intended for the desktop app, use `just build-ui`; the Tauri binary embeds `frontend/dist` at cargo build time. diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index 14d9ed5a..99a74d96 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -57,13 +57,15 @@ After setup, the Capsem service runs in the background (like Docker). It starts ## First session -Boot a sandboxed VM and get a shell: +Open the Capsem TUI: ```sh capsem shell ``` -This creates a temporary Linux session with an air-gapped network. You get a terminal inside the VM session with Python 3, Node.js, git, and 30+ packages pre-installed. The session is destroyed when you exit. +The TUI lets you start the service if it is offline, create or resume sessions, +and switch between VM terminals. New Linux sessions run with an air-gapped +network and include Python 3, Node.js, git, and 30+ packages pre-installed. For a persistent session that survives suspend/resume cycles: diff --git a/docs/src/content/docs/usage/cli.md b/docs/src/content/docs/usage/cli.md index 782544c7..f833d4d0 100644 --- a/docs/src/content/docs/usage/cli.md +++ b/docs/src/content/docs/usage/cli.md @@ -69,17 +69,18 @@ capsem create -e API_KEY=sk-... # with environment variables ### shell -Open an interactive shell. With no arguments, creates a temporary session that is destroyed on exit. +Open the Capsem TUI. With no arguments, opens the home/create flow. Pass a +session name or ID to focus the TUI on that session. ```sh -capsem shell # temp session (destroyed on exit) -capsem shell mybox # attach to existing session -capsem shell abc123 # find by ID +capsem shell # open the TUI +capsem shell mybox # open focused on a named session +capsem shell abc123 # open focused on an ID ``` | Arg | Description | |-----|-------------| -| `[SESSION]` | Name or ID of an existing session | +| `[SESSION]` | Optional name or ID to focus in the TUI | ### resume diff --git a/justfile b/justfile index 01e283e2..02431032 100644 --- a/justfile +++ b/justfile @@ -9,7 +9,7 @@ # _ensure-service kills any running service, launches a fresh one, waits for socket # # User-facing recipe chains: -# shell -> _check-assets + _pack-initrd + _ensure-service (daily dev entry point) +# shell -> _check-assets + _pack-initrd + _ensure-service + TUI # ui -> _ensure-setup + _pnpm-install + run-service (service + Tauri dev hot-reload) # run-service -> _check-assets + _pack-initrd + _ensure-service (start daemon, idempotent) # exec +CMD -> run-service (one-shot command in a fresh temp VM) @@ -36,7 +36,7 @@ # just doctor (shows what's missing; `just doctor fix` auto-installs) # just build-assets (builds kernel + rootfs -- needs docker via Colima on macOS) # -# Daily dev: just shell (service daemon + temp VM + shell, ~10s) +# Daily dev: just shell (service daemon + TUI, ~10s) # just ui (service + Tauri GUI with hot-reload) # just exec "" (one-shot command in a temp VM) # Local install: just install (hard clean + native package install + status/VM network gate) @@ -293,7 +293,7 @@ run-ui *ARGS: build-ui sleep 1 ./target/debug/capsem-app {{ARGS}} -# Start service daemon + boot temporary VM + shell (~10s after first build) +# Start service daemon + open the TUI (~10s after first build) shell: _check-assets _pack-initrd _ensure-service #!/bin/bash set -euo pipefail diff --git a/sprints/tui-control/MASTER.md b/sprints/tui-control/MASTER.md index bfd614fe..38815035 100644 --- a/sprints/tui-control/MASTER.md +++ b/sprints/tui-control/MASTER.md @@ -103,8 +103,12 @@ attention markers. gateway WebSocket command returned `TUI_WS_PROOF_A` from `tui-proof-a`. - Replaced the temporary terminal text parser with `vt100`, preserving xterm screen state, SGR colors, and text attributes. Client-side adjacent output - coalescing and dirty-frame redraws now mirror the existing `capsem shell` - speed contract instead of repainting on every loop. + coalescing and dirty-frame redraws now preserve the old shell speed contract + without repainting on every loop. +- Cut over `capsem shell` to launch `capsem-tui`, making the TUI the only + public interactive VM shell surface. `capsem shell ` now opens the + TUI focused on that session; bare `capsem shell` opens the TUI home/create + flow. - Tightened interactive control polish: help opens on `Alt+?`, overlays render as Ratatui modal blocks, service latency renders as a glued `####ms●` segment, and active terminal geometry is resent whenever the real terminal @@ -199,3 +203,5 @@ attention markers. name editing, help discoverability, and authenticated gateway fork payloads. - Checkpoint regression: `cargo test -p capsem-tui` covers `Alt+c` confirmation and the authenticated checkpoint request over the current suspend endpoint. +- Shell cutover regression: `cargo test -p capsem` covers CLI parsing and + `capsem shell` to `capsem-tui --session` argument mapping. diff --git a/sprints/tui-control/tracker.md b/sprints/tui-control/tracker.md index 11895b75..258ac397 100644 --- a/sprints/tui-control/tracker.md +++ b/sprints/tui-control/tracker.md @@ -45,6 +45,10 @@ - [x] Run live installed-gateway empty-service snapshot. - [x] Run live two-session terminal proof. - [x] Commit functional milestone. +- [x] Replace legacy `capsem shell` with `capsem-tui`. +- [x] Remove old CLI shell PTY bridge code. +- [x] Add CLI/TUI cutover tests. +- [ ] Commit v1 shell cutover. ## Notes @@ -197,6 +201,9 @@ - Token-refresh correction: after a service/gateway restart, the provider now clears a stale cached gateway token and retries one load with a fresh `/token`, so a successful start can converge back to live service state. +- V1 shell cutover: `capsem shell` is now the stable public entry point for + the TUI, not a second terminal implementation. The legacy direct IPC PTY + bridge is intentionally removed so terminal semantics live in one surface. ## Coverage Ledger @@ -212,6 +219,10 @@ checkpoint-over-suspend payloads, raw local latency preservation coverage, profile discovery failure behavior for empty services, and local `capsem start` invocation without requiring a gateway token. +- CLI shell cutover: `cargo test -p capsem` covers CLI parsing and + `capsem shell` argument mapping to `capsem-tui`; a black-box command with + `CAPSEM_SHELL_TUI_BINARY=/bin/echo target/debug/capsem shell my-vm` proves + the actual binary dispatch emits `--session my-vm`. - Process IPC: `cargo test -p capsem-process` (120 tests), including `connection_teardown_aborts_writer_and_lifecycle_tasks`. - Service/core/logger hot paths: `cargo test -p capsem-service`, From 9b168fd520e4d2f1174e928f6b14c964076896f2 Mon Sep 17 00:00:00 2001 From: Elie Bursztein Date: Sat, 30 May 2026 10:37:20 -0400 Subject: [PATCH 32/35] fix: focus tui create and hide corrupt tabs --- CHANGELOG.md | 7 ++ crates/capsem-tui/src/app.rs | 100 +++++++++++++++++++--- crates/capsem-tui/src/fixture.rs | 2 + crates/capsem-tui/src/gateway_provider.rs | 18 +++- crates/capsem-tui/src/main.rs | 5 ++ crates/capsem-tui/src/model.rs | 1 + crates/capsem-tui/src/tests.rs | 79 ++++++++++++++++- crates/capsem-tui/src/ui.rs | 57 +++++++++--- sprints/tui-control/MASTER.md | 11 +++ sprints/tui-control/tracker.md | 22 +++-- 10 files changed, 271 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3daebd77..94886e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cold-booting. ### Fixed +- Fixed `capsem-tui` create flow focus so a newly provisioned VM becomes the + active tab after the gateway refresh instead of leaving focus on the previous + session. +- Fixed `capsem-tui` corrupted profile-pin handling so non-resumable sessions + are hidden from the bottom VM tab strip, still appear in the full `Alt+l` + session inventory, and explain that the VM must be recreated from a signed + profile if explicitly selected. - Fixed `capsem-tui` service-offline startup so the TUI shows an offline service surface and asks to start Capsem before opening the new-session flow; confirming the prompt runs the local `capsem start` command and refreshes diff --git a/crates/capsem-tui/src/app.rs b/crates/capsem-tui/src/app.rs index 2691d7ff..0c5cbbb1 100644 --- a/crates/capsem-tui/src/app.rs +++ b/crates/capsem-tui/src/app.rs @@ -99,6 +99,7 @@ impl App { create_draft: None, fork_draft: None, }; + app.ensure_active_tab_visible(); app.sync_empty_state_prompt(); app } @@ -139,7 +140,7 @@ impl App { .position(|session| session.id == state.active_session_id) .unwrap_or_default(); self.state = state; - self.sync_active_session(); + self.ensure_active_tab_visible(); self.sync_empty_state_prompt(); } @@ -178,12 +179,22 @@ impl App { return AppAction::Consumed; } } + if self.resume_key_is_blocked(key) { + if let Some(reason) = self.active_resume_blocked_reason() { + self.set_control_message(reason); + } + return AppAction::Consumed; + } if let Some(action) = self.control_action_for_key(key) { self.pending_action = Some(action); self.overlay = AppOverlay::Confirm; return AppAction::Consumed; } if key.code == KeyCode::Enter && key.modifiers.is_empty() { + if let Some(reason) = self.active_resume_blocked_reason() { + self.set_control_message(reason); + return AppAction::Consumed; + } if let Some(action) = self.active_resume_action() { return AppAction::Invoke(action); } @@ -204,30 +215,41 @@ impl App { } pub fn next_session(&mut self) { - if self.state.sessions.is_empty() { + let visible = visible_session_indices(&self.state); + if visible.is_empty() { return; } - self.active_index = (self.active_index + 1) % self.state.sessions.len(); + let position = visible + .iter() + .position(|index| *index == self.active_index) + .unwrap_or_default(); + self.active_index = visible[(position + 1) % visible.len()]; self.sync_active_session(); } pub fn previous_session(&mut self) { - if self.state.sessions.is_empty() { + let visible = visible_session_indices(&self.state); + if visible.is_empty() { return; } - self.active_index = if self.active_index == 0 { - self.state.sessions.len() - 1 + let position = visible + .iter() + .position(|index| *index == self.active_index) + .unwrap_or_default(); + self.active_index = if position == 0 { + visible[visible.len() - 1] } else { - self.active_index - 1 + visible[position - 1] }; self.sync_active_session(); } pub fn select_session(&mut self, index: usize) { - if index >= self.state.sessions.len() { + let visible = visible_session_indices(&self.state); + let Some(actual_index) = visible.get(index).copied() else { return; - } - self.active_index = index; + }; + self.active_index = actual_index; self.sync_active_session(); } @@ -240,10 +262,29 @@ impl App { else { return false; }; - self.select_session(index); + self.active_index = index; + self.sync_active_session(); true } + fn ensure_active_tab_visible(&mut self) { + if self + .state + .sessions + .get(self.active_index) + .is_some_and(session_visible_in_tabs) + { + self.sync_active_session(); + return; + } + let Some(index) = self.state.sessions.iter().position(session_visible_in_tabs) else { + self.sync_active_session(); + return; + }; + self.active_index = index; + self.sync_active_session(); + } + fn sync_active_session(&mut self) { let Some(session) = self.state.sessions.get(self.active_index) else { return; @@ -320,6 +361,12 @@ impl App { } } + fn resume_key_is_blocked(&self, key: KeyEvent) -> bool { + is_alt_key(key.modifiers) + && matches!(key.code, KeyCode::Char('r' | 'R')) + && self.active_resume_blocked_reason().is_some() + } + fn active_resume_action(&self) -> Option { let session = self.state.active_session()?; if !matches!( @@ -328,11 +375,18 @@ impl App { ) { return None; } + if resume_blocked_reason(session).is_some() { + return None; + } Some(ControlAction::Resume { name: session.id.clone(), }) } + fn active_resume_blocked_reason(&self) -> Option<&'static str> { + self.state.active_session().and_then(resume_blocked_reason) + } + fn active_checkpoint_action(&self) -> Option { let session = self.state.active_session()?; if !session.persistent || !matches!(session.lifecycle, SessionLifecycle::Working) { @@ -535,6 +589,30 @@ fn selected_profile_id(state: &AppState, index: usize) -> Option { .map(|profile| profile.id.clone()) } +pub fn resume_blocked_reason(session: &crate::model::SessionSummary) -> Option<&'static str> { + let status = session.profile_status.as_deref()?.to_ascii_lowercase(); + if matches!( + status.as_str(), + "ready" | "ok" | "installed" | "active" | "current" + ) { + return None; + } + Some("cannot resume: profile pin is corrupted; recreate from a signed profile") +} + +pub fn session_visible_in_tabs(session: &crate::model::SessionSummary) -> bool { + resume_blocked_reason(session).is_none() +} + +fn visible_session_indices(state: &AppState) -> Vec { + state + .sessions + .iter() + .enumerate() + .filter_map(|(index, session)| session_visible_in_tabs(session).then_some(index)) + .collect() +} + fn next_tmp_name(state: &AppState) -> String { for index in 1..1000 { let candidate = format!("tmp-{index}"); diff --git a/crates/capsem-tui/src/fixture.rs b/crates/capsem-tui/src/fixture.rs index 0f678087..6949a536 100644 --- a/crates/capsem-tui/src/fixture.rs +++ b/crates/capsem-tui/src/fixture.rs @@ -47,6 +47,7 @@ pub fn fixture_state() -> AppState { title: "Profile V2".to_string(), repo_path: Some("github.com/google/capsem".to_string()), profile: "corp-default".to_string(), + profile_status: Some("current".to_string()), branch: Some("codex/tui-control".to_string()), persistent: true, lifecycle: SessionLifecycle::Working, @@ -64,6 +65,7 @@ pub fn fixture_state() -> AppState { title: "Linux OS".to_string(), repo_path: Some("github.com/google/capsem-linux".to_string()), profile: "linux-builder".to_string(), + profile_status: Some("current".to_string()), branch: Some("resume-fix".to_string()), persistent: true, lifecycle: SessionLifecycle::WaitingForInput, diff --git a/crates/capsem-tui/src/gateway_provider.rs b/crates/capsem-tui/src/gateway_provider.rs index 233c9236..a65bd1c5 100644 --- a/crates/capsem-tui/src/gateway_provider.rs +++ b/crates/capsem-tui/src/gateway_provider.rs @@ -262,7 +262,12 @@ fn vm_response_to_summary(vm: VmSummary) -> SessionSummary { id, title, repo_path: None, - profile: vm.profile_id.unwrap_or_else(|| "default".to_string()), + profile: vm + .profile_id + .clone() + .or_else(|| vm.profile_status.clone()) + .unwrap_or_else(|| "default".to_string()), + profile_status: vm.profile_status, branch: vm.profile_revision, persistent: vm.persistent, lifecycle, @@ -314,7 +319,7 @@ fn attention_from_vm(vm: &VmSummary, lifecycle: SessionLifecycle) -> Vec) -> u64 { #[derive(Clone, Debug, Eq, PartialEq)] pub struct ActionOutcome { pub message: String, + pub focus_session: Option, } async fn invoke_action( @@ -361,6 +367,7 @@ async fn invoke_action( .unwrap_or("session"); Ok(ActionOutcome { message: format!("created {id}"), + focus_session: Some(id.to_string()), }) } ControlAction::Fork { id, name } => { @@ -378,30 +385,35 @@ async fn invoke_action( .unwrap_or(name); Ok(ActionOutcome { message: format!("forked {fork_name}"), + focus_session: Some(fork_name.to_string()), }) } ControlAction::Resume { name } => { post_empty(client, base_url, token, &["resume", name]).await?; Ok(ActionOutcome { message: format!("resumed {name}"), + focus_session: Some(name.clone()), }) } ControlAction::Checkpoint { id } => { post_empty(client, base_url, token, &["suspend", id]).await?; Ok(ActionOutcome { message: format!("checkpointed {id}"), + focus_session: Some(id.clone()), }) } ControlAction::Suspend { id } => { post_empty(client, base_url, token, &["suspend", id]).await?; Ok(ActionOutcome { message: format!("suspended {id}"), + focus_session: Some(id.clone()), }) } ControlAction::Stop { id } => { post_empty(client, base_url, token, &["stop", id]).await?; Ok(ActionOutcome { message: format!("stopped {id}"), + focus_session: Some(id.clone()), }) } ControlAction::Delete { id } => { @@ -414,6 +426,7 @@ async fn invoke_action( response_json(response).await?; Ok(ActionOutcome { message: format!("deleted {id}"), + focus_session: None, }) } } @@ -437,6 +450,7 @@ pub(crate) async fn start_service_with_binary(binary: &Path) -> Result { app.set_control_message(outcome.message); + focus_after_refresh = outcome.focus_session; should_refresh = true; } ControlEvent::Finished(Err(error)) => { @@ -188,6 +190,9 @@ fn run_loop( } if should_refresh { needs_draw |= refresh_state(app, live_provider.as_ref()); + if let Some(session_id) = focus_after_refresh { + needs_draw |= app.select_session_by_id(&session_id); + } } } if let Some(bridge) = &terminal_bridge { diff --git a/crates/capsem-tui/src/model.rs b/crates/capsem-tui/src/model.rs index 2fdaf9f5..3747afcf 100644 --- a/crates/capsem-tui/src/model.rs +++ b/crates/capsem-tui/src/model.rs @@ -62,6 +62,7 @@ pub struct SessionSummary { pub title: String, pub repo_path: Option, pub profile: String, + pub profile_status: Option, pub branch: Option, pub persistent: bool, pub lifecycle: SessionLifecycle, diff --git a/crates/capsem-tui/src/tests.rs b/crates/capsem-tui/src/tests.rs index 0eae048d..59e22473 100644 --- a/crates/capsem-tui/src/tests.rs +++ b/crates/capsem-tui/src/tests.rs @@ -133,6 +133,7 @@ async fn start_service_action_uses_local_capsem_binary_without_gateway_token() { .expect("start service command"); assert_eq!(outcome.message, "service start requested"); + assert_eq!(outcome.focus_session, None); } #[test] @@ -224,6 +225,71 @@ fn enter_resumes_stopped_active_session_instead_of_forwarding_to_terminal() { ); } +#[test] +fn corrupted_profile_session_blocks_resume_and_explains_recreate() { + let mut state = fixture_state(); + state.sessions[0].lifecycle = SessionLifecycle::Idle; + state.sessions[0].profile_status = Some("corrupted".to_string()); + state.sessions[0].attention = vec![Attention::CredentialIssue]; + let mut app = App::new(state); + assert!(app.select_session_by_id("profile-v2")); + + let snapshot = render_app_snapshot(&app, 100, 24).expect("render corrupted profile session"); + assert!(snapshot.contains("cannot resume: profile pin is corrupted")); + assert!(!snapshot.contains("Press Enter to resume")); + + assert_eq!( + app.handle_key(key(KeyCode::Enter, KeyModifiers::NONE)), + AppAction::Consumed + ); + assert_eq!( + app.state().service.control_message.as_deref(), + Some("cannot resume: profile pin is corrupted; recreate from a signed profile") + ); + + assert_eq!( + app.handle_key(key(KeyCode::Char('r'), KeyModifiers::ALT)), + AppAction::Consumed + ); + assert_eq!(app.pending_action(), None); +} + +#[test] +fn corrupted_profile_sessions_are_hidden_from_tabs_but_stay_in_vm_list() { + let mut state = fixture_state(); + state.sessions[0].lifecycle = SessionLifecycle::Idle; + state.sessions[0].profile_status = Some("corrupted".to_string()); + state.sessions[0].attention = vec![Attention::CredentialIssue]; + let mut app = App::new(state); + + assert_eq!( + app.state().active_session_id, + "linux-os", + "startup focus should move to the first resumable tab instead of a corrupt profile pin" + ); + let snapshot = render_app_snapshot(&app, 100, 24).expect("render filtered tabs"); + assert!(!snapshot.contains("profile-v2")); + assert!(snapshot.contains("1 linux-os!")); + + assert_eq!( + app.handle_key(key(KeyCode::Char('l'), KeyModifiers::ALT)), + AppAction::Consumed + ); + let list_snapshot = render_app_snapshot(&app, 120, 30).expect("render session inventory"); + assert!(list_snapshot.contains("Profile V2")); + assert!(list_snapshot.contains("corrupted")); + + assert_eq!( + app.handle_key(key(KeyCode::Char('1'), KeyModifiers::ALT)), + AppAction::Consumed + ); + assert_eq!( + app.state().active_session_id, + "linux-os", + "tab number 1 should map to the first visible tab, not the hidden corrupt session" + ); +} + #[test] fn keyboard_navigation_switches_sessions_without_stealing_plain_q() { let mut app = App::new(fixture_state()); @@ -687,6 +753,11 @@ fn gateway_status_json_maps_to_tui_state() { let attention = &state.sessions[1]; assert_eq!(attention.lifecycle, SessionLifecycle::Suspended); assert!(attention.attention.contains(&Attention::PolicyDeny)); + assert_eq!(attention.profile_status.as_deref(), Some("corrupted")); + assert!( + attention.attention.contains(&Attention::CredentialIssue), + "corrupted profile status should be surfaced as a credential/profile issue" + ); } #[test] @@ -905,6 +976,7 @@ async fn gateway_provider_invokes_named_profile_create_over_authenticated_gatewa .expect("invoke create"); assert_eq!(outcome.message, "created tmp-1-proof"); + assert_eq!(outcome.focus_session.as_deref(), Some("tmp-1-proof")); server.await.expect("server task"); } @@ -944,6 +1016,10 @@ async fn gateway_provider_invokes_fork_over_authenticated_gateway() { .expect("invoke fork"); assert_eq!(outcome.message, "forked profile-v2-fork-copy"); + assert_eq!( + outcome.focus_session.as_deref(), + Some("profile-v2-fork-copy") + ); server.await.expect("server task"); } @@ -1115,7 +1191,7 @@ async fn write_json_response(stream: &mut tokio::net::TcpStream, body: &str) { async fn write_response(stream: &mut tokio::net::TcpStream, status: &str, body: &str) { let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", body.len(), body ); @@ -1153,6 +1229,7 @@ fn gateway_status_body() -> &'static str { "status": "Suspended", "persistent": true, "profile_id": "linux-os", + "profile_status": "corrupted", "uptime_secs": 7860, "total_input_tokens": 10000, "total_output_tokens": 2900, diff --git a/crates/capsem-tui/src/ui.rs b/crates/capsem-tui/src/ui.rs index d61d5292..7d282805 100644 --- a/crates/capsem-tui/src/ui.rs +++ b/crates/capsem-tui/src/ui.rs @@ -7,7 +7,10 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use ratatui::{Frame, Terminal}; -use crate::app::{App, AppOverlay, ControlAction, CreateDraft, ForkDraft}; +use crate::app::{ + resume_blocked_reason, session_visible_in_tabs, App, AppOverlay, ControlAction, CreateDraft, + ForkDraft, +}; use crate::model::{AppState, ServiceStatus, SessionLifecycle, SessionSummary}; use crate::terminal::{TerminalColor, TerminalLine, TerminalStyle, TerminalSurface}; @@ -243,7 +246,7 @@ fn render_waiting_terminal_surface(frame: &mut Frame<'_>, area: Rect, session: & } fn render_inactive_session_surface(frame: &mut Frame<'_>, area: Rect, session: &SessionSummary) { - let lines = vec![ + let mut lines = vec![ Line::from(Span::styled( session.id.clone(), muted_style().add_modifier(Modifier::BOLD), @@ -252,11 +255,18 @@ fn render_inactive_session_surface(frame: &mut Frame<'_>, area: Rect, session: & inactive_session_label(session.lifecycle), muted_style(), )), - Line::from(Span::styled( + ]; + if let Some(reason) = resume_blocked_reason(session) { + lines.push(Line::from(Span::styled( + reason, + bad_style().add_modifier(Modifier::BOLD), + ))); + } else { + lines.push(Line::from(Span::styled( "Press Enter to resume", status_base_style().add_modifier(Modifier::BOLD), - )), - ]; + ))); + } frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); } @@ -579,7 +589,7 @@ fn home_lines(state: &AppState) -> Vec> { "{active} {:<2} {:<18} {:<14} {:<10} {:>6} {:>7} ${:<5}", index + 1, truncate(&session.title, 18), - truncate(&session.profile, 14), + truncate(&profile_inventory_label(session), 14), session.lifecycle.label(), format_duration(session.stats.duration), format_tokens(session.stats.tokens), @@ -594,6 +604,16 @@ fn home_lines(state: &AppState) -> Vec> { lines } +fn profile_inventory_label(session: &SessionSummary) -> String { + if resume_blocked_reason(session).is_some() { + return session + .profile_status + .clone() + .unwrap_or_else(|| "profile-error".to_string()); + } + session.profile.clone() +} + fn overlay_title(title: &'static str) -> Line<'static> { Line::from(Span::styled( format!(" {title}"), @@ -680,14 +700,27 @@ fn info_row(field: &'static str, value: &str, note: impl AsRef) -> Line<'st } fn tab_spans(state: &AppState, active_index: usize, max_width: usize) -> Vec> { - let visible = visible_tab_range(state.sessions.len(), active_index); + let tab_sessions = state + .sessions + .iter() + .enumerate() + .filter(|(_, session)| session_visible_in_tabs(session)) + .collect::>(); + if tab_sessions.is_empty() { + return Vec::new(); + } + let active_tab_index = tab_sessions + .iter() + .position(|(index, _)| *index == active_index) + .unwrap_or_default(); + let visible = visible_tab_range(tab_sessions.len(), active_tab_index); let mut spans = Vec::new(); let mut used = 0; if visible.start > 0 { push_budgeted(&mut spans, "< | ", muted_style(), max_width, &mut used); } - for (offset, session) in state.sessions[visible.clone()].iter().enumerate() { - let index = visible.start + offset; + for (offset, (session_index, session)) in tab_sessions[visible.clone()].iter().enumerate() { + let tab_index = visible.start + offset; let separator = if offset == 0 && visible.start == 0 { "" } else { @@ -707,16 +740,16 @@ fn tab_spans(state: &AppState, active_index: usize, max_width: usize) -> Vec