diff --git a/Cargo.toml b/Cargo.toml index 7ede408f..25519ed3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -129,6 +129,7 @@ no-plugin = [ # The release workflow builds the Windows target with this set. windows-default = ['no-plugin'] loop = [] +vigil = [] # Run dirge itself as an MCP server (`dirge mcp`) so another agent (e.g. # Claude Code) can delegate implementation tasks to dirge and review them. # Pulls rmcp's server side + stdio transport + the tool macros. diff --git a/docs/config.md b/docs/config.md index 03caa7ac..568cb52a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -147,6 +147,7 @@ Accepted top-level keys: | `mcp_servers` | object | MCP server map when compiled with the `mcp` feature. When omitted, defaults to a single Exa Web Search server; see below. | | `acp_servers` | object | ACP server config map when compiled with the `acp` feature. See the ACP section below. | | `editor_open_command` | string | Opt-in editor follow-along: a command template with `{path}` and `{line}` placeholders (e.g. `"zed {path}:{line}"`, `"code --goto {path}:{line}"`). When set, dirge opens files it reads or edits in this external GUI editor, detached and non-blocking — the editor "follows along" like Zed's AI panel. `None` (unset) disables the feature entirely. | +| `vigils` | array | Vigil definitions consulted only when `--vigil` is active (compiled with the `vigil` feature). Each entry: `name`, a `trigger` (`toll` timer with `interval_secs`, `watcher` on a `path`, or `harbinger` TCP socket with `address` and `socket_mode`), an optional `reap_interval_secs` (default 30), an optional `prompt` for the observance turn, and optional `procession` (Janet) and `rite` gate. | ### Desktop Notifications diff --git a/src/cli.rs b/src/cli.rs index 66485653..f2f47de1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -307,6 +307,48 @@ pub enum Command { #[arg(long = "sandbox")] sandbox: Option, }, + /// Manage vigils — list, add, remove, pause, resume, restart. + #[cfg(feature = "vigil")] + Vigil { + #[command(subcommand)] + action: VigilAction, + }, +} + +/// Vigil management subcommands. +#[cfg(feature = "vigil")] +#[derive(clap::Subcommand, Debug)] +pub enum VigilAction { + /// List all configured vigils and their status. + List, + /// Add a new vigil trigger. + Add { + /// Vigil name. + name: String, + /// Trigger type: toll, watcher, or harbinger. + #[arg(value_enum)] + trigger: VigilAddTrigger, + /// Additional trigger args as key=value pairs. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Remove a vigil by name. + Remove { name: String }, + /// Pause a running vigil. + Pause { name: String }, + /// Resume a paused vigil. + Resume { name: String }, + /// Restart a vigil (stop and re-create its trigger). + Rest { name: String }, +} + +/// Trigger type for `dirge vigil add`. +#[cfg(feature = "vigil")] +#[derive(clap::ValueEnum, Debug, Clone)] +pub enum VigilAddTrigger { + Toll, + Watcher, + Harbinger, } #[derive(clap::Subcommand, Debug)] diff --git a/src/config/mod.rs b/src/config/mod.rs index 739dca3f..9736a1ae 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use serde::Deserialize; +#[cfg(feature = "vigil")] +use serde::Serialize; use crate::session::storage; @@ -1367,6 +1369,92 @@ pub struct Config { /// future expansion but are not honored today. #[cfg(feature = "acp")] pub acp_servers: Option>, + + /// Vigil definitions loaded from `config.toml` under `[vigils.]`. + /// Only consulted when `--vigil` is active. + #[cfg(feature = "vigil")] + #[serde(default)] + pub vigils: Option>, +} + +/// A single vigil definition from config. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[serde(default)] +pub struct VigilEntry { + pub name: String, + pub trigger: VigilTrigger, + #[serde(default = "default_reap_interval")] + pub reap_interval_secs: u64, + #[serde(default)] + pub prompt: String, + /// Optional Janet script for per-observance procession. + #[serde(default)] + pub procession: Option, + #[serde(default)] + pub rite: Option, +} + +#[cfg(feature = "vigil")] +fn default_reap_interval() -> u64 { + 30 +} + +/// What triggers a vigil to fire. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum VigilTrigger { + /// Timer-based: fires every N seconds. + Toll { interval_secs: u64 }, + /// Filesystem watcher: fires on changes under `path`. + Watcher { path: String }, + /// Network socket: external process sends events to a TCP port. + Harbinger { + address: String, + #[serde(default)] + protocol: String, + /// `template` or `commands` — see `SocketMode`. + #[serde(default)] + socket_mode: SocketMode, + #[serde(default)] + commands: HashMap, + }, +} + +#[cfg(feature = "vigil")] +impl Default for VigilTrigger { + fn default() -> Self { + VigilTrigger::Toll { interval_secs: 30 } + } +} + +/// Harbinger socket mode. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum SocketMode { + #[default] + Template, + Commands, +} + +/// A pre-registered command for `commands` socket mode. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct VigilCommand { + pub tool: String, + #[serde(default)] + pub args: serde_json::Map, +} + +/// Optional gate condition checked before an observance runs. +#[cfg(feature = "vigil")] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct VigilRite { + pub cmd: Option, + #[serde(default)] + pub git_dirty: bool, } impl Config { diff --git a/src/extras/mod.rs b/src/extras/mod.rs index 3b4f3207..9f8628ee 100644 --- a/src/extras/mod.rs +++ b/src/extras/mod.rs @@ -42,3 +42,4 @@ pub mod session_search; pub mod skill_db; pub mod skills; pub mod spec_db; +pub mod vigil_db; diff --git a/src/extras/vigil_db.rs b/src/extras/vigil_db.rs new file mode 100644 index 00000000..94fa733f --- /dev/null +++ b/src/extras/vigil_db.rs @@ -0,0 +1,253 @@ +//! SQLite store for vigil heartbeat/wakeup configurations. +//! +//! Vigil entries live in the per-project session DB (`.dirge/sessions/state.db`). +//! The store owns its schema via idempotent `CREATE TABLE IF NOT EXISTS` on open. +#![allow(dead_code)] + +use std::path::Path; +use std::sync::Mutex; + +use rusqlite::{Connection, OpenFlags, OptionalExtension, params}; + +/// Lifecycle states for a vigil. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VigilStatus { + Active, + Paused, + Resting, +} + +impl VigilStatus { + pub fn as_str(&self) -> &'static str { + match self { + VigilStatus::Active => "active", + VigilStatus::Paused => "paused", + VigilStatus::Resting => "resting", + } + } +} + +/// A stored vigil row. +pub struct VigilRow { + pub name: String, + pub payload_json: String, + pub status: VigilStatus, + pub created_at: String, + pub updated_at: String, +} + +/// SQLite-backed vigil store. +pub struct VigilStore { + conn: Mutex, +} + +impl VigilStore { + pub fn open(paths: &super::dirge_paths::ProjectPaths) -> Result { + Self::open_at(&paths.session_db_path()) + } + + pub fn open_at(path: &Path) -> Result { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, + ) + .map_err(|e| format!("open vigil db at {}: {e}", path.display()))?; + let _ = conn.busy_timeout(std::time::Duration::from_secs(5)); + let _ = conn.pragma_update(None, "journal_mode", "WAL"); + let store = Self { + conn: Mutex::new(conn), + }; + store.ensure_schema()?; + Ok(store) + } + + fn ensure_schema(&self) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS vigils ( + name TEXT PRIMARY KEY NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_vigils_status ON vigils(status);", + ) + .map_err(|e| format!("create vigils table: {e}")) + } + + pub fn upsert(&self, name: &str, payload_json: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO vigils (name, payload_json, status, updated_at) + VALUES (?1, ?2, 'active', datetime('now')) + ON CONFLICT(name) DO UPDATE SET + payload_json = excluded.payload_json, + status = 'active', + updated_at = datetime('now')", + params![name, payload_json], + ) + .map_err(|e| format!("upsert vigil {name}: {e}"))?; + Ok(()) + } + + pub fn set_status(&self, name: &str, status: VigilStatus) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + let affected = conn + .execute( + "UPDATE vigils SET status = ?1, updated_at = datetime('now') WHERE name = ?2", + params![status.as_str(), name], + ) + .map_err(|e| format!("set status for vigil {name}: {e}"))?; + if affected == 0 { + return Err(format!("vigil {name} not found")); + } + Ok(()) + } + + pub fn remove(&self, name: &str) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + let affected = conn + .execute("DELETE FROM vigils WHERE name = ?1", params![name]) + .map_err(|e| format!("remove vigil {name}: {e}"))?; + if affected == 0 { + return Err(format!("vigil {name} not found")); + } + Ok(()) + } + + pub fn get(&self, name: &str) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT name, payload_json, status, created_at, updated_at + FROM vigils WHERE name = ?1", + ) + .map_err(|e| format!("prepare get vigil {name}: {e}"))?; + let row = stmt + .query_row(params![name], |row| { + Ok(VigilRow { + name: row.get(0)?, + payload_json: row.get(1)?, + status: { + let s: String = row.get(2)?; + match s.as_str() { + "active" => VigilStatus::Active, + "paused" => VigilStatus::Paused, + "resting" => VigilStatus::Resting, + _ => VigilStatus::Active, + } + }, + created_at: row.get(3)?, + updated_at: row.get(4)?, + }) + }) + .optional() + .map_err(|e| format!("get vigil {name}: {e}"))?; + Ok(row) + } + + pub fn list_non_resting(&self) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT name, payload_json, status, created_at, updated_at + FROM vigils WHERE status != 'resting' ORDER BY name", + ) + .map_err(|e| format!("prepare list_non_resting: {e}"))?; + let rows = stmt + .query_map([], |row| { + Ok(VigilRow { + name: row.get(0)?, + payload_json: row.get(1)?, + status: { + let s: String = row.get(2)?; + match s.as_str() { + "active" => VigilStatus::Active, + "paused" => VigilStatus::Paused, + "resting" => VigilStatus::Resting, + _ => VigilStatus::Active, + } + }, + created_at: row.get(3)?, + updated_at: row.get(4)?, + }) + }) + .map_err(|e| format!("list_non_resting: {e}"))? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + static COUNTER: AtomicU32 = AtomicU32::new(0); + + fn temp_db() -> (VigilStore, std::path::PathBuf) { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = + std::env::temp_dir().join(format!("dirge-vigildb-test-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let store = VigilStore::open_at(&dir.join("state.db")).unwrap(); + (store, dir) + } + + #[test] + fn upsert_then_get_roundtrips_payload() { + let (store, _dir) = temp_db(); + store.upsert("poll", "{\"name\":\"poll\"}").unwrap(); + let row = store.get("poll").unwrap().expect("row exists"); + assert_eq!(row.name, "poll"); + assert_eq!(row.payload_json, "{\"name\":\"poll\"}"); + assert_eq!(row.status, VigilStatus::Active); + } + + #[test] + fn upsert_resets_status_to_active() { + let (store, _dir) = temp_db(); + store.upsert("poll", "v1").unwrap(); + store.set_status("poll", VigilStatus::Paused).unwrap(); + store.upsert("poll", "v2").unwrap(); + let row = store.get("poll").unwrap().unwrap(); + assert_eq!(row.status, VigilStatus::Active); + assert_eq!(row.payload_json, "v2"); + } + + #[test] + fn list_non_resting_excludes_resting() { + let (store, _dir) = temp_db(); + store.upsert("a", "1").unwrap(); + store.upsert("b", "2").unwrap(); + store.upsert("c", "3").unwrap(); + store.set_status("b", VigilStatus::Resting).unwrap(); + let names: Vec = store + .list_non_resting() + .unwrap() + .into_iter() + .map(|r| r.name) + .collect(); + assert_eq!(names, vec!["a", "c"]); + } + + #[test] + fn remove_deletes_row() { + let (store, _dir) = temp_db(); + store.upsert("poll", "1").unwrap(); + store.remove("poll").unwrap(); + assert!(store.get("poll").unwrap().is_none()); + } + + #[test] + fn status_and_remove_on_missing_name_error() { + let (store, _dir) = temp_db(); + assert!(store.set_status("nope", VigilStatus::Paused).is_err()); + assert!(store.remove("nope").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 4007cdfe..2542ad09 100644 --- a/src/main.rs +++ b/src/main.rs @@ -573,6 +573,8 @@ async fn main() -> anyhow::Result<()> { cli::Command::Sandbox { .. } => {} #[cfg(feature = "mcp-server")] cli::Command::Mcp { .. } => {} + #[cfg(feature = "vigil")] + cli::Command::Vigil { .. } => {} } } @@ -747,6 +749,11 @@ async fn main() -> anyhow::Result<()> { cli::Command::Mcp { model, sandbox } => { return extras::mcp_server::serve(&cli, &cfg, model.clone(), sandbox.clone()).await; } + #[cfg(feature = "vigil")] + cli::Command::Vigil { action } => { + handle_vigil_command(action).await?; + return Ok(()); + } } } @@ -2686,3 +2693,165 @@ mod resume_staleness_tests { ); } } + +/// Handle `dirge vigil add/list/remove/pause/resume/rest` subcommands. +#[cfg(feature = "vigil")] +async fn handle_vigil_command(action: &crate::cli::VigilAction) -> anyhow::Result<()> { + use crate::extras::dirge_paths::ProjectPaths; + use crate::extras::vigil_db::{VigilStatus, VigilStore}; + + let paths = ProjectPaths::new( + &std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + ); + + match action { + crate::cli::VigilAction::List => { + let vigils = config::load().vigils.unwrap_or_default(); + println!("Vigils (from config):"); + for v in &vigils { + let trigger = match &v.trigger { + crate::config::VigilTrigger::Toll { interval_secs } => { + format!("toll every {interval_secs}s") + } + crate::config::VigilTrigger::Watcher { path } => { + format!("watcher on {path}") + } + crate::config::VigilTrigger::Harbinger { + address, protocol, .. + } => { + let p = if protocol.is_empty() { + "tcp" + } else { + protocol.as_str() + }; + format!("harbinger {p}://{address}") + } + }; + let prompt = if v.prompt.is_empty() { + "(default)".to_string() + } else { + v.prompt.clone() + }; + println!( + " {} - {trigger} - reap every {}s - prompt: {prompt}", + v.name, v.reap_interval_secs + ); + } + if vigils.is_empty() { + println!(" (none)"); + } + } + crate::cli::VigilAction::Add { + name, + trigger, + args, + } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + let entry = build_vigil_entry(name, trigger, args)?; + let json = serde_json::to_string(&entry)?; + store + .upsert(&entry.name, &json) + .map_err(|e| anyhow::anyhow!("{e}"))?; + println!( + "Added vigil '{}'. Run `dirge --vigil` to start the keeper.", + entry.name + ); + } + crate::cli::VigilAction::Remove { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + match store.remove(name) { + Ok(()) => println!("Removed vigil '{name}'."), + Err(e) => eprintln!("{e}"), + } + } + crate::cli::VigilAction::Pause { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + match store.set_status(name, VigilStatus::Paused) { + Ok(()) => println!("Paused vigil '{name}'."), + Err(e) => eprintln!("{e}"), + } + } + crate::cli::VigilAction::Resume { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + match store.set_status(name, VigilStatus::Active) { + Ok(()) => println!("Resumed vigil '{name}'."), + Err(e) => eprintln!("{e}"), + } + } + crate::cli::VigilAction::Rest { name } => { + let store = VigilStore::open(&paths).map_err(|e| anyhow::anyhow!("{e}"))?; + match store.set_status(name, VigilStatus::Resting) { + Ok(()) => println!("vigil '{name}' resting (will sleep until next trigger)."), + Err(e) => eprintln!("{e}"), + } + } + } + Ok(()) +} + +/// Build a VigilEntry from CLI `vigil add` args. +#[cfg(feature = "vigil")] +fn build_vigil_entry( + name: &str, + trigger: &crate::cli::VigilAddTrigger, + args: &[String], +) -> anyhow::Result { + use crate::config::{VigilEntry, VigilRite, VigilTrigger}; + + let parsed: std::collections::HashMap = args + .iter() + .filter_map(|a| a.split_once('=')) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let trigger = match trigger { + crate::cli::VigilAddTrigger::Toll => { + let secs = parsed + .get("interval_secs") + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + VigilTrigger::Toll { + interval_secs: secs, + } + } + crate::cli::VigilAddTrigger::Watcher => { + let path = parsed + .get("path") + .cloned() + .unwrap_or_else(|| ".".to_string()); + VigilTrigger::Watcher { path } + } + crate::cli::VigilAddTrigger::Harbinger => { + let address = parsed + .get("address") + .cloned() + .unwrap_or_else(|| "127.0.0.1:9000".to_string()); + let protocol = parsed.get("protocol").cloned().unwrap_or_default(); + VigilTrigger::Harbinger { + address, + protocol, + socket_mode: crate::config::SocketMode::Commands, + commands: std::collections::HashMap::new(), + } + } + }; + + let reap_interval_secs = parsed + .get("reap_interval_secs") + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + + let prompt = parsed.get("prompt").cloned().unwrap_or_default(); + + Ok(VigilEntry { + name: name.to_string(), + trigger, + reap_interval_secs, + prompt, + procession: None, + rite: Some(VigilRite { + cmd: None, + git_dirty: false, + }), + }) +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 94ef6781..21c8d41e 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -10,6 +10,8 @@ mod learning_loop_tests; mod picker_tests; #[cfg(all(test, feature = "semantic"))] mod semantic_tests; +#[cfg(all(test, feature = "vigil"))] +mod vigil_tests; use ctor::ctor; // Install rustls ring crypto provider before any test runs. // Tests bypass main(), so the provider install in main() is not diff --git a/src/tests/vigil_tests.rs b/src/tests/vigil_tests.rs new file mode 100644 index 00000000..db06f89b --- /dev/null +++ b/src/tests/vigil_tests.rs @@ -0,0 +1,106 @@ +//! Tests for the `dirge vigil` CLI management layer: entry parsing and the +//! vigil config serde shape. Gated on the `vigil` feature because every type +//! under test (VigilEntry, VigilTrigger, VigilAddTrigger) is cfg-gated too. + +use crate::cli::VigilAddTrigger; +use crate::config::{SocketMode, VigilEntry, VigilTrigger}; + +fn args(values: &[&str]) -> Vec { + values.iter().map(|s| s.to_string()).collect() +} + +#[test] +fn toll_entry_uses_defaults() { + let entry = crate::build_vigil_entry("poll", &VigilAddTrigger::Toll, &[]).unwrap(); + assert_eq!(entry.name, "poll"); + assert!(matches!( + entry.trigger, + VigilTrigger::Toll { interval_secs: 30 } + )); + assert_eq!(entry.reap_interval_secs, 30); + assert!(entry.prompt.is_empty()); + assert!(entry.rite.is_some()); +} + +#[test] +fn toll_entry_parses_interval_and_reap() { + let entry = crate::build_vigil_entry( + "poll", + &VigilAddTrigger::Toll, + &args(&["interval_secs=60", "reap_interval_secs=10", "prompt=hi"]), + ) + .unwrap(); + assert!(matches!( + entry.trigger, + VigilTrigger::Toll { interval_secs: 60 } + )); + assert_eq!(entry.reap_interval_secs, 10); + assert_eq!(entry.prompt, "hi"); +} + +#[test] +fn watcher_entry_parses_path() { + let entry = + crate::build_vigil_entry("w", &VigilAddTrigger::Watcher, &args(&["path=/tmp/watch"])) + .unwrap(); + assert!(matches!(entry.trigger, VigilTrigger::Watcher { path } if path == "/tmp/watch")); +} + +#[test] +fn watcher_entry_defaults_path_to_dot() { + let entry = crate::build_vigil_entry("w", &VigilAddTrigger::Watcher, &[]).unwrap(); + assert!(matches!(entry.trigger, VigilTrigger::Watcher { path } if path == ".")); +} + +#[test] +fn harbinger_entry_defaults_address_and_commands_mode() { + let entry = crate::build_vigil_entry("h", &VigilAddTrigger::Harbinger, &[]).unwrap(); + match entry.trigger { + VigilTrigger::Harbinger { + address, + protocol, + socket_mode, + commands, + } => { + assert_eq!(address, "127.0.0.1:9000"); + assert!(protocol.is_empty()); + assert_eq!(socket_mode, SocketMode::Commands); + assert!(commands.is_empty()); + } + other => panic!("expected harbinger, got {other:?}"), + } +} + +#[test] +fn config_deserializes_toll_with_defaults() { + let entry: VigilEntry = + serde_json::from_str(r#"{"name":"poll","trigger":{"type":"toll","interval_secs":45}}"#) + .unwrap(); + assert!(matches!( + entry.trigger, + VigilTrigger::Toll { interval_secs: 45 } + )); + assert_eq!(entry.reap_interval_secs, 30); + assert!(entry.prompt.is_empty()); +} + +#[test] +fn config_deserializes_harbinger_kebab_case() { + let entry: VigilEntry = serde_json::from_str( + r#"{"name":"jh","trigger":{"type":"harbinger","address":"127.0.0.1:9001","socket_mode":"commands"}}"#, + ) + .unwrap(); + match entry.trigger { + VigilTrigger::Harbinger { + address, + protocol, + socket_mode, + .. + } => { + assert_eq!(address, "127.0.0.1:9001"); + assert!(protocol.is_empty()); + assert_eq!(socket_mode, SocketMode::Commands); + } + other => panic!("expected harbinger, got {other:?}"), + } +}