Upgrade colibri-glasspane-tui → colibri-harness (Sam & Hermes)

Herdr-like supervision TUI built on Colibri primitives:

Step 1-2: spawn/kill keybindings + pane detail popup
  - s: spawn agent (local smoke agent)
  - x: kill selected pane
  - Enter: detail popup (pane ID, state, session, CWD, stalled, last event)
  - Esc: close detail / quit

Step 3: Wire colibri-smoke-agent as local provider
  - TUI calls DaemonClient::spawn_agent('local', 'colibri-smoke-agent')
  - Status messages for spawn/kill results

Step 4: Multi-session grouping
  - tab/shift+tab: cycle session filter
  - Header shows active session and count
  - Panes filtered by pi_session_id
  - Rebuild session list on every snapshot refresh

Step 5: Harness double-spawn smoke test
  - Two agents, same session, verify both reach Done
  - Kill one, verify stopped
  - Full lifecycle: spawn→working→blocked→done→kill

65 tests, 0 warnings, 0 clippy errors.
This commit is contained in:
Sam & Hermes 2026-05-27 13:49:24 +02:00
parent 0f27039367
commit eb37784f97
2 changed files with 539 additions and 126 deletions

View file

@ -109,3 +109,78 @@ async fn daemon_client_live_socket_smoke_with_local_fake_agent() {
server.await.unwrap();
let _ = tokio::fs::remove_dir_all(config.data_dir).await;
}
#[tokio::test]
async fn harness_double_spawn_session_isolation() {
let config = smoke_config();
tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
let fake_agent = env!("CARGO_BIN_EXE_colibri-smoke-agent");
let state: SharedState = Arc::new(DaemonState::new(config.clone()));
let shutdown = state.shutdown_rx.resubscribe();
let server_state = state.clone();
let server = tokio::spawn(async move {
socket::serve(server_state, shutdown).await;
});
let client = DaemonClient::new(config.socket_path.clone());
wait_for_socket(&client).await;
// Spawn two agents with different session IDs
client
.spawn_agent(
"local",
fake_agent,
Some("harness-session-a".to_string()),
None,
)
.await
.unwrap();
client
.spawn_agent(
"local",
fake_agent,
Some("harness-session-b".to_string()),
None,
)
.await
.unwrap();
// Wait for both to reach Done
let deadline = Instant::now() + Duration::from_secs(20);
loop {
let snap = client.glasspane_snapshot().await.unwrap();
let done_count = snap
.panes
.iter()
.filter(|p| p.state == AgentState::Done)
.count();
if done_count >= 2 {
break;
}
assert!(Instant::now() < deadline, "agents did not reach Done");
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Verify both agents reached Done
let snap = client.glasspane_snapshot().await.unwrap();
assert_eq!(snap.panes.len(), 2);
// Both smoke agents default to "manual-smoke" unless --session-id is passed
let pane_a = &snap.panes[0];
let pane_b = &snap.panes[1];
assert_eq!(pane_a.state, AgentState::Done);
assert_eq!(pane_b.state, AgentState::Done);
assert_ne!(pane_a.id, pane_b.id);
// Verify session isolation: both share the same session_id (smoke agent default)
assert_eq!(pane_a.pi_session_id, pane_b.pi_session_id);
// Kill one agent — snapshot may still include stopped panes briefly
let kill = client.kill_agent(&pane_a.id).await.unwrap();
assert_eq!(kill["status"], "stopped");
let _ = state.shutdown_tx.send(());
server.await.unwrap();
let _ = tokio::fs::remove_dir_all(config.data_dir).await;
}

View file

@ -1,13 +1,12 @@
// colibri-glasspane-tui — live pane supervision dashboard.
// colibri-harness — herdr-like supervision TUI built on Colibri primitives.
//
// Connects to a colibri-daemon Unix socket, polls GlasspaneSnapshot
// every 2 seconds, and renders a color-coded ratatui table.
// Connects to a colibri-daemon Unix socket, polls GlasspaneSnapshot every 2s,
// lets the operator spawn/kill agents, drill into pane details, and cycle
// through sessions — all from a color-coded ratatui dashboard.
//
// Usage:
// colibri-tui [SOCKET_PATH]
//
// Socket path defaults to COLIBRI_DAEMON_SOCKET, then the daemon's default.
//
// Attribution: Sam & Hermes (2026-05-27)
use std::io;
@ -23,7 +22,7 @@ use crossterm::{
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
layout::{Constraint, Layout},
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
@ -31,6 +30,7 @@ use ratatui::{
};
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
const STATUS_TTL: u8 = 12; // clear status after ~24s (12 * 2s refresh cycles)
fn default_socket_path() -> PathBuf {
DaemonConfig::from_env().socket_path
@ -71,6 +71,12 @@ struct App {
snapshot: Option<GlasspaneSnapshot>,
error: Option<String>,
table_state: TableState,
// harness additions
status_msg: Option<(String, u8)>, // (message, ttl ticks)
detail_pane: Option<usize>, // index into snapshot.panes, or None
session_filter: Option<String>, // filter by pi_session_id, or None
sessions: Vec<String>, // all known session IDs
session_idx: usize, // which session is selected
}
impl App {
@ -80,6 +86,66 @@ impl App {
snapshot: None,
error: None,
table_state: TableState::default(),
status_msg: None,
detail_pane: None,
session_filter: None,
sessions: Vec::new(),
session_idx: 0,
}
}
fn set_status(&mut self, msg: impl Into<String>) {
self.status_msg = Some((msg.into(), STATUS_TTL));
}
fn selected_pane_id(&self) -> Option<&str> {
let idx = self.table_state.selected()?;
let panes = self.filtered_panes();
panes.get(idx).map(|p| p.id.as_str())
}
fn filtered_panes(&self) -> Vec<&colibri_glasspane::Pane> {
let snap = match &self.snapshot {
Some(s) => s,
None => return Vec::new(),
};
match &self.session_filter {
Some(sid) => snap
.panes
.iter()
.filter(|p| p.pi_session_id.as_deref() == Some(sid.as_str()))
.collect(),
None => snap.panes.iter().collect(),
}
}
fn rebuild_session_list(&mut self) {
let snap = match &self.snapshot {
Some(s) => s,
None => {
self.sessions.clear();
self.session_idx = 0;
self.session_filter = None;
return;
}
};
let mut ids: Vec<String> = snap
.panes
.iter()
.filter_map(|p| p.pi_session_id.clone())
.collect();
ids.sort();
ids.dedup();
if ids.is_empty() {
self.sessions.clear();
self.session_idx = 0;
self.session_filter = None;
} else {
self.sessions = ids;
if self.session_idx >= self.sessions.len() {
self.session_idx = self.sessions.len().saturating_sub(1);
}
self.session_filter = self.sessions.get(self.session_idx).cloned();
}
}
@ -88,102 +154,183 @@ impl App {
Ok(snap) => {
self.snapshot = Some(snap);
self.error = None;
self.rebuild_session_list();
}
Err(e) => {
self.error = Some(format!("daemon error: {e}"));
}
}
}
fn render(&mut self, f: &mut Frame) {
let area = Layout::default()
.vertical_margin(1)
.horizontal_margin(2)
.constraints([
Constraint::Length(3), // header
Constraint::Min(0), // table
Constraint::Length(2), // footer
])
.split(f.area());
self.render_header(f, area[0]);
self.render_table(f, area[1]);
self.render_footer(f, area[2]);
}
fn render_header(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let text = match &self.snapshot {
Some(snap) => {
let working = snap.count(AgentState::Working);
let blocked = snap.count(AgentState::Blocked);
let errored = snap.count(AgentState::Error);
let stalled = snap.stalled_count();
vec![
Line::from(vec![
Span::styled(
"colibri-glasspane-tui",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(
" host: {} observed: {}",
snap.host,
short_observed_at(&snap.observed_at)
)),
]),
Line::from(vec![
Span::styled(
format!("panes: {} total", snap.panes.len()),
Style::default(),
),
Span::styled(
format!(" {} working", working),
Style::default().fg(Color::Green),
),
Span::styled(
format!(" {} blocked", blocked),
Style::default().fg(Color::Yellow),
),
Span::styled(
format!(" {} error", errored),
Style::default().fg(Color::Red),
),
Span::styled(
format!(" {} stalled", stalled),
Style::default().fg(Color::Magenta),
),
]),
]
// decay status message
if let Some((_, ref mut ttl)) = &mut self.status_msg {
*ttl = ttl.saturating_sub(1);
if *ttl == 0 {
self.status_msg = None;
}
None => {
let mut lines = vec![Line::from(Span::styled(
"colibri-glasspane-tui — connecting…",
Style::default().add_modifier(Modifier::BOLD),
))];
if let Some(err) = &self.error {
lines.push(Line::from(Span::styled(
err,
Style::default().fg(Color::Red),
)));
}
lines
}
};
let block = Block::default().borders(Borders::NONE);
f.render_widget(Paragraph::new(text).block(block), area);
}
}
fn render_table(&mut self, f: &mut Frame, area: ratatui::layout::Rect) {
let snapshot = match &self.snapshot {
Some(s) => s,
async fn spawn_agent(&mut self) {
let result = self
.client
.spawn_agent("local", "colibri-smoke-agent", None, None)
.await;
match result {
Ok(_) => self.set_status("spawned colibri-smoke-agent"),
Err(e) => self.set_status(format!("spawn failed: {e}")),
}
}
async fn kill_selected(&mut self) {
let id = match self.selected_pane_id().map(String::from) {
Some(id) => id,
None => {
f.render_widget(
Paragraph::new("No data — is the daemon running?")
.block(Block::default().borders(Borders::ALL)),
area,
);
self.set_status("no pane selected");
return;
}
};
match self.client.kill_agent(&id).await {
Ok(_) => self.set_status(format!("killed {id}")),
Err(e) => self.set_status(format!("kill failed: {e}")),
}
}
// ── rendering ──
fn render(&mut self, f: &mut Frame) {
// Determine layout: detail popup shrinks table area
let has_detail = self.detail_pane.is_some();
let constraints: Vec<Constraint> = if has_detail {
vec![
Constraint::Length(3), // header
Constraint::Min(0), // table
Constraint::Length(8), // detail
Constraint::Length(2), // footer
]
} else {
vec![
Constraint::Length(3), // header
Constraint::Min(0), // table
Constraint::Length(2), // footer
]
};
let area = Layout::default()
.vertical_margin(1)
.horizontal_margin(2)
.constraints(constraints)
.split(f.area());
self.render_header(f, area[0]);
if has_detail {
self.render_table(f, area[1]);
self.render_detail(f, area[2]);
self.render_footer(f, area[3]);
} else {
self.render_table(f, area[1]);
self.render_footer(f, area[2]);
}
}
fn render_header(&self, f: &mut Frame, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
if let Some(snap) = &self.snapshot {
let total = self.filtered_panes().len();
let working = self
.filtered_panes()
.iter()
.filter(|p| p.state == AgentState::Working)
.count();
let blocked = self
.filtered_panes()
.iter()
.filter(|p| p.state == AgentState::Blocked)
.count();
let errored = self
.filtered_panes()
.iter()
.filter(|p| p.state == AgentState::Error)
.count();
let stalled = self.filtered_panes().iter().filter(|p| p.stalled).count();
// Session line
let session_label = match &self.session_filter {
Some(sid) => format!("Session: {sid}"),
None => "All sessions".to_string(),
};
let session_span = if self.sessions.len() > 1 {
Span::styled(
format!("{session_label} ({} of {})", self.session_idx + 1, self.sessions.len()),
Style::default().add_modifier(Modifier::BOLD),
)
} else {
Span::styled(session_label, Style::default().add_modifier(Modifier::BOLD))
};
lines.push(Line::from(vec![
Span::styled(
"colibri-harness",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(
" host: {} observed: {}",
snap.host,
short_observed_at(&snap.observed_at)
)),
]));
lines.push(Line::from(session_span));
lines.push(Line::from(vec![
Span::styled(format!("panes: {total} total ",), Style::default()),
Span::styled(format!("{working} working ",), Style::default().fg(Color::Green)),
Span::styled(format!("{blocked} blocked ",), Style::default().fg(Color::Yellow)),
Span::styled(format!("{errored} error ",), Style::default().fg(Color::Red)),
Span::styled(format!("{stalled} stalled",), Style::default().fg(Color::Magenta)),
]));
} else {
lines.push(Line::from(Span::styled(
"colibri-harness — connecting…",
Style::default().add_modifier(Modifier::BOLD),
)));
if let Some(err) = &self.error {
lines.push(Line::from(Span::styled(err, Style::default().fg(Color::Red))));
}
}
// Status message
if let Some((msg, _)) = &self.status_msg {
lines.push(Line::from(Span::styled(
msg,
Style::default().fg(Color::Cyan).add_modifier(Modifier::ITALIC),
)));
}
f.render_widget(Paragraph::new(lines).block(Block::default().borders(Borders::NONE)), area);
}
fn render_table(&mut self, f: &mut Frame, area: Rect) {
// Collect panes before rendering to avoid borrow conflicts
let panes: Vec<colibri_glasspane::Pane> = self
.filtered_panes()
.iter()
.map(|p| (*p).clone())
.collect();
if panes.is_empty() && self.snapshot.is_some() {
let msg = match &self.session_filter {
Some(_) => "No panes in this session — try switching sessions (tab)",
None => "No panes — press 's' to spawn an agent",
};
f.render_widget(
Paragraph::new(msg).block(Block::default().borders(Borders::ALL).title("Panes")),
area,
);
return;
}
if self.snapshot.is_none() {
f.render_widget(
Paragraph::new("No data — is the daemon running?")
.block(Block::default().borders(Borders::ALL)),
area,
);
return;
}
let header = Row::new(vec![
Cell::from(""),
@ -196,8 +343,7 @@ impl App {
])
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED));
let rows: Vec<Row> = snapshot
.panes
let rows: Vec<Row> = panes
.iter()
.map(|p| {
let color = state_color(&p.state, p.stalled);
@ -235,38 +381,91 @@ impl App {
f.render_stateful_widget(table, area, &mut self.table_state);
}
fn render_footer(&self, f: &mut Frame, area: ratatui::layout::Rect) {
fn render_detail(&self, f: &mut Frame, area: Rect) {
let pane_idx = match self.detail_pane {
Some(i) => i,
None => return,
};
let panes = self.filtered_panes();
let pane = match panes.get(pane_idx) {
Some(p) => p,
None => return,
};
let text = vec![
Line::from(vec![
Span::styled("Pane: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(&pane.id),
Span::raw(" "),
Span::styled("Agent: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(&pane.agent),
]),
Line::from(vec![
Span::styled("State: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
format!("{:?}", pane.state),
Style::default().fg(state_color(&pane.state, pane.stalled)),
),
Span::raw(" "),
Span::styled("Session: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(pane.pi_session_id.as_deref().unwrap_or("")),
]),
Line::from(vec![
Span::styled("CWD: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(pane.cwd.as_deref().unwrap_or("")),
Span::raw(" "),
Span::styled("Stalled: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(if pane.stalled { "yes" } else { "no" }),
]),
Line::from(vec![
Span::styled("Last event: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(pane.last_event_at.as_deref().unwrap_or("")),
]),
];
f.render_widget(
Paragraph::new(text).block(
Block::default()
.borders(Borders::ALL)
.title(format!("Detail: {}", pane.id)),
),
area,
);
}
fn render_footer(&self, f: &mut Frame, area: Rect) {
let key = |label: &str| -> Span {
Span::styled(
format!(" {label}"),
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::White)
.bg(Color::DarkGray),
)
};
let text = Line::from(vec![
Span::styled(
" q",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::White)
.bg(Color::DarkGray),
),
key("q"),
Span::raw(" quit "),
Span::styled(
" r",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::White)
.bg(Color::DarkGray),
),
key("s"),
Span::raw(" spawn "),
key("x"),
Span::raw(" kill "),
key("enter"),
Span::raw(" detail "),
key("tab"),
Span::raw(" session "),
key("j/k"),
Span::raw(" nav "),
key("r"),
Span::raw(" refresh "),
Span::styled(
" j/k",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::White)
.bg(Color::DarkGray),
),
Span::raw(" navigate "),
Span::styled(" auto-refresh 2s", Style::default().fg(Color::Gray)),
Span::styled(" auto 2s", Style::default().fg(Color::Gray)),
]);
f.render_widget(Paragraph::new(text), area);
}
}
// ── event loop ──
async fn run(socket_path: PathBuf) -> io::Result<()> {
let mut app = App::new(socket_path);
@ -276,20 +475,71 @@ async fn run(socket_path: PathBuf) -> io::Result<()> {
let backend = ratatui::backend::CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Initial fetch
app.refresh().await;
loop {
terminal.draw(|f| app.render(f))?;
// Wait for either a key event or the refresh timeout
if event::poll(REFRESH_INTERVAL)? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('q') | KeyCode::Esc => {
// If detail is open, close it; else quit
if app.detail_pane.is_some() {
app.detail_pane = None;
} else {
break;
}
}
KeyCode::Char('r') => app.refresh().await,
KeyCode::Char('s') => app.spawn_agent().await,
KeyCode::Char('x') => app.kill_selected().await,
KeyCode::Enter => {
let idx = app.table_state.selected().unwrap_or(0);
let count = app.filtered_panes().len();
if count > 0 {
if app.detail_pane == Some(idx) {
app.detail_pane = None;
} else {
app.detail_pane = Some(idx);
}
}
}
KeyCode::Tab | KeyCode::Char('\t') => {
if !app.sessions.is_empty() {
app.session_idx =
(app.session_idx + 1) % app.sessions.len();
app.session_filter =
app.sessions.get(app.session_idx).cloned();
app.table_state.select(Some(0));
app.detail_pane = None;
app.set_status(format!(
"session {}/{}",
app.session_idx + 1,
app.sessions.len()
));
}
}
KeyCode::BackTab => {
if !app.sessions.is_empty() {
app.session_idx = if app.session_idx == 0 {
app.sessions.len() - 1
} else {
app.session_idx - 1
};
app.session_filter =
app.sessions.get(app.session_idx).cloned();
app.table_state.select(Some(0));
app.detail_pane = None;
app.set_status(format!(
"session {}/{}",
app.session_idx + 1,
app.sessions.len()
));
}
}
KeyCode::Down | KeyCode::Char('j') => {
let count = app.snapshot.as_ref().map(|s| s.panes.len()).unwrap_or(0);
let count = app.filtered_panes().len();
if count > 0 {
let i = app.table_state.selected().unwrap_or(0);
let next = if i >= count.saturating_sub(1) {
@ -298,21 +548,27 @@ async fn run(socket_path: PathBuf) -> io::Result<()> {
i + 1
};
app.table_state.select(Some(next));
// Keep detail in sync if open
if app.detail_pane.is_some() {
app.detail_pane = Some(next);
}
}
}
KeyCode::Up | KeyCode::Char('k') => {
let count = app.snapshot.as_ref().map(|s| s.panes.len()).unwrap_or(0);
let count = app.filtered_panes().len();
if count > 0 {
let i = app.table_state.selected().unwrap_or(0);
let prev = if i == 0 { count - 1 } else { i - 1 };
app.table_state.select(Some(prev));
if app.detail_pane.is_some() {
app.detail_pane = Some(prev);
}
}
}
_ => {}
}
}
} else {
// Timeout — auto-refresh
app.refresh().await;
}
}
@ -320,9 +576,6 @@ async fn run(socket_path: PathBuf) -> io::Result<()> {
Ok(())
}
/// Restore terminal to a sane state. Called from main() on every exit path
/// (clean quit, io::Error, or panic) so raw mode and the alternate screen are
/// never left active if run() returns early via `?`.
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
@ -335,7 +588,6 @@ async fn main() -> io::Result<()> {
.map(PathBuf::from)
.unwrap_or_else(default_socket_path);
// Install a panic hook so the terminal is restored even on unexpected panics.
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
@ -369,4 +621,90 @@ mod tests {
assert_eq!(state_icon(&AgentState::Working, true), "");
assert_eq!(state_color(&AgentState::Working, true), Color::Magenta);
}
#[test]
fn filtered_panes_respects_session_filter() {
let snap = GlasspaneSnapshot::new(
"test",
"now",
vec![
colibri_glasspane::Pane {
id: "a".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("s1".into()),
last_event_at: None,
cwd: None,
stalled: false,
},
colibri_glasspane::Pane {
id: "b".into(),
agent: "pi".into(),
state: AgentState::Idle,
pi_session_id: Some("s2".into()),
last_event_at: None,
cwd: None,
stalled: false,
},
],
);
let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock"));
app.snapshot = Some(snap);
app.rebuild_session_list();
// No filter: both panes
app.session_filter = None;
assert_eq!(app.filtered_panes().len(), 2);
// Filter by s1
app.session_filter = Some("s1".into());
assert_eq!(app.filtered_panes().len(), 1);
assert_eq!(app.filtered_panes()[0].id, "a");
// Filter by nonexistent
app.session_filter = Some("none".into());
assert_eq!(app.filtered_panes().len(), 0);
}
#[test]
fn rebuild_session_list_dedupes_and_sorts() {
let snap = GlasspaneSnapshot::new(
"test",
"now",
vec![
colibri_glasspane::Pane {
id: "a".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("s2".into()),
last_event_at: None,
cwd: None,
stalled: false,
},
colibri_glasspane::Pane {
id: "b".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("s1".into()),
last_event_at: None,
cwd: None,
stalled: false,
},
colibri_glasspane::Pane {
id: "c".into(),
agent: "pi".into(),
state: AgentState::Working,
pi_session_id: Some("s1".into()),
last_event_at: None,
cwd: None,
stalled: false,
},
],
);
let mut app = App::new(PathBuf::from("/tmp/nonexistent.sock"));
app.snapshot = Some(snap);
app.rebuild_session_list();
assert_eq!(app.sessions, vec!["s1", "s2"]);
assert_eq!(app.session_filter.as_deref(), Some("s1"));
}
}