From 53e5480d103d518ada4d1f00454b500ae0fa749b Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 15:15:28 +0000 Subject: [PATCH 01/18] hum-paths: single source of truth for on-disk paths New hum-paths crate. init() sets XDG defaults at startup; every callsite routes through it. No /tmp fallback. Socket moves to $XDG_STATE_HOME/hum. --- Cargo.lock | 154 +++++--------------------- Cargo.toml | 1 + codegen/src/lib.rs | 17 +-- config/Cargo.toml | 2 +- config/src/lib.rs | 11 +- hives/bp7/Cargo.toml | 2 +- hives/bp7/src/main.rs | 7 +- hives/claude-cli/Cargo.toml | 1 + hives/claude-cli/src/main.rs | 3 +- hives/claude-repl/Cargo.toml | 1 + hives/claude-repl/src/main.rs | 1 + hives/common/Cargo.toml | 1 + hives/common/src/forager.rs | 18 +-- hives/common/src/identity.rs | 18 +-- hives/common/src/serve.rs | 19 +--- hives/grpc/Cargo.toml | 2 +- hives/grpc/src/main.rs | 8 +- hives/gsm-modem/Cargo.toml | 2 +- hives/gsm-modem/src/main.rs | 7 +- hives/humfs/Cargo.toml | 1 + hives/humfs/src/main.rs | 1 + hives/ollama-server/Cargo.toml | 2 +- hives/ollama-server/src/main.rs | 14 +-- hives/openai-server/pnpm-lock.yaml | 16 ++- hives/paid-oracle/Cargo.toml | 2 +- hives/paid-oracle/src/main.rs | 7 +- hum-paths/Cargo.toml | 8 ++ hum-paths/src/lib.rs | 140 +++++++++++++++++++++++ hum/Cargo.toml | 1 + hum/src/main.rs | 91 +++++---------- humd/Cargo.toml | 1 + humd/examples/smoke.rs | 16 +-- humd/src/identity.rs | 18 +-- humd/src/lib.rs | 21 +--- humd/src/main.rs | 1 + humd/src/peers.rs | 16 +-- hums/Cargo.toml | 2 +- hums/src/lib.rs | 18 +-- thrum-clients/go/thrum/helpers.go | 11 +- thrum-clients/python/thrum/helpers.py | 6 +- thrumd/Cargo.toml | 1 + thrumd/src/lib.rs | 19 +--- 42 files changed, 284 insertions(+), 404 deletions(-) create mode 100644 hum-paths/Cargo.toml create mode 100644 hum-paths/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ac0c1c06..9d26945e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,7 +432,7 @@ dependencies = [ "anyhow", "bp7 0.10.7", "ensemble", - "libc", + "hum-paths", "nest-common", "serde", "serde_json", @@ -569,6 +569,7 @@ version = "0.31.18" dependencies = [ "anyhow", "async-trait", + "hum-paths", "nest", "nest-common", "serde_json", @@ -584,6 +585,7 @@ version = "0.31.18" dependencies = [ "anyhow", "async-trait", + "hum-paths", "nest", "nest-common", "portable-pty", @@ -645,7 +647,7 @@ dependencies = [ name = "config" version = "0.31.18" dependencies = [ - "directories", + "hum-paths", "jsonschema", "serde", "serde_json", @@ -1032,27 +1034,6 @@ dependencies = [ "crypto-common 0.2.1", ] -[[package]] -name = "directories" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - [[package]] name = "discard" version = "1.0.4" @@ -1576,7 +1557,7 @@ dependencies = [ "anyhow", "async-stream", "ensemble", - "libc", + "hum-paths", "nest-common", "prost", "serde_json", @@ -1598,7 +1579,7 @@ dependencies = [ "ensemble", "futures-util", "hex", - "libc", + "hum-paths", "nest-common", "serde", "serde_json", @@ -1820,6 +1801,7 @@ dependencies = [ "anyhow", "clap", "config", + "hum-paths", "humd", "serde_json", "thrum-core", @@ -1828,6 +1810,10 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "hum-paths" +version = "0.31.18" + [[package]] name = "humantime" version = "2.3.0" @@ -1846,6 +1832,7 @@ dependencies = [ "ed25519-dalek 2.2.0", "ensemble", "hex", + "hum-paths", "hums", "ids", "mcp", @@ -1871,6 +1858,7 @@ dependencies = [ "async-trait", "base64", "config", + "hum-paths", "mime_guess", "nest", "nest-common", @@ -1894,7 +1882,7 @@ dependencies = [ name = "hums" version = "0.31.18" dependencies = [ - "directories", + "hum-paths", "parking_lot", "serde", "serde_json", @@ -2540,15 +2528,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2827,6 +2806,7 @@ dependencies = [ "ed25519-dalek 2.2.0", "ensemble", "futures", + "hum-paths", "mcp", "nest", "parking_lot", @@ -3281,7 +3261,7 @@ dependencies = [ "axum", "bytes", "chrono", - "libc", + "hum-paths", "serde", "serde_json", "thrum-core", @@ -3321,12 +3301,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "outref" version = "0.5.2" @@ -3340,7 +3314,7 @@ dependencies = [ "anyhow", "ensemble", "hex", - "libc", + "hum-paths", "nest-common", "parking_lot", "reqwest 0.12.28", @@ -3916,17 +3890,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -4916,6 +4879,7 @@ version = "0.31.18" dependencies = [ "anyhow", "async-trait", + "hum-paths", "parking_lot", "serde", "serde_json", @@ -5858,22 +5822,13 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -5885,35 +5840,20 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5925,36 +5865,18 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5967,48 +5889,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 07d6639b..007f19bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "thrum-core", "thrumd", + "hum-paths", "ids", "config", "codegen", diff --git a/codegen/src/lib.rs b/codegen/src/lib.rs index b8d678b7..c68e7be5 100644 --- a/codegen/src/lib.rs +++ b/codegen/src/lib.rs @@ -525,12 +525,12 @@ class WaneTracker: def default_socket_path() -> str: """Resolve the humd thrum socket per WIRE.md priority: - HUM_THRUM_SOCK > $XDG_RUNTIME_DIR/hum/thrum.sock > /run/user//hum/thrum.sock.""" + HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/thrum.sock > ~/.local/state/hum/thrum.sock.""" explicit = os.environ.get("HUM_THRUM_SOCK") if explicit: return explicit - runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.geteuid()}" - return os.path.join(runtime, "hum", "thrum.sock") + state = os.environ.get("XDG_STATE_HOME") or os.path.join(os.path.expanduser("~"), ".local", "state") + return os.path.join(state, "hum", "thrum.sock") "#; SRC.to_string() } @@ -690,16 +690,17 @@ func (w *WaneTracker) Behind(sigil string, remote int64) bool { } // DefaultSocketPath resolves the humd thrum socket per WIRE.md priority: -// HUM_THRUM_SOCK > $XDG_RUNTIME_DIR/hum/thrum.sock > /run/user//hum/thrum.sock. +// HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/thrum.sock > ~/.local/state/hum/thrum.sock. func DefaultSocketPath() string { if explicit := os.Getenv("HUM_THRUM_SOCK"); explicit != "" { return explicit } - runtime := os.Getenv("XDG_RUNTIME_DIR") - if runtime == "" { - runtime = fmt.Sprintf("/run/user/%d", os.Geteuid()) + state := os.Getenv("XDG_STATE_HOME") + if state == "" { + home, _ := os.UserHomeDir() + state = filepath.Join(home, ".local", "state") } - return filepath.Join(runtime, "hum", "thrum.sock") + return filepath.Join(state, "hum", "thrum.sock") } "#; SRC.to_string() diff --git a/config/Cargo.toml b/config/Cargo.toml index 46f19094..34319dc0 100644 --- a/config/Cargo.toml +++ b/config/Cargo.toml @@ -8,6 +8,6 @@ description = "hum.json loader — XDG-located, best-effort, defaults fill missi [dependencies] serde = { workspace = true } serde_json = { workspace = true } -directories = { workspace = true } tracing = { workspace = true } jsonschema = "0.46.5" +hum-paths = { path = "../hum-paths" } diff --git a/config/src/lib.rs b/config/src/lib.rs index 6c3b7323..9dfbfa8b 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -7,7 +7,6 @@ use std::path::{Path, PathBuf}; -use directories::BaseDirs; use serde::{Deserialize, Serialize}; use tracing::warn; @@ -149,15 +148,7 @@ pub struct HumConfig { // ── path resolution ─────────────────────────────────────────────────────── pub fn config_path() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - if !xdg.is_empty() { - return PathBuf::from(xdg).join("hum").join("hum.json"); - } - } - if let Some(base) = BaseDirs::new() { - return base.config_dir().join("hum").join("hum.json"); - } - PathBuf::from(".config/hum/hum.json") + hum_paths::hum_json() } /// Expand `~` against `$HOME`. Leaves absolute / non-tilde paths alone. diff --git a/hives/bp7/Cargo.toml b/hives/bp7/Cargo.toml index 42c1b65b..1665f32e 100644 --- a/hives/bp7/Cargo.toml +++ b/hives/bp7/Cargo.toml @@ -16,7 +16,7 @@ anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } bundle-protocol = { package = "bp7", version = "0.10" } -libc = "0.2" +hum-paths = { path = "../../hum-paths" } [[bin]] name = "bp7-forager" diff --git a/hives/bp7/src/main.rs b/hives/bp7/src/main.rs index 558e54c2..6a239822 100644 --- a/hives/bp7/src/main.rs +++ b/hives/bp7/src/main.rs @@ -58,23 +58,20 @@ struct Config { impl Config { fn from_env() -> Result { - let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| { - format!("/run/user/{}", unsafe { libc::geteuid() }) - }); let listen_str = std::env::var("BP7_LISTEN").unwrap_or_else(|_| DEFAULT_LISTEN.into()); Ok(Self { listen: listen_str.parse().with_context(|| format!("parse BP7_LISTEN={listen_str}"))?, node_eid: std::env::var("BP7_NODE_EID") .unwrap_or_else(|_| "dtn://hum.local/inference".into()), model: std::env::var("BP7_MODEL").unwrap_or_else(|_| "claude-sonnet-4".into()), - sock_path: std::env::var("HUM_THRUM_SOCK") - .unwrap_or_else(|_| format!("{runtime}/hum/thrum.sock")), + sock_path: hum_paths::thrum_sock().to_string_lossy().into_owned(), }) } } #[tokio::main] async fn main() -> Result<()> { + hum_paths::init(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() diff --git a/hives/claude-cli/Cargo.toml b/hives/claude-cli/Cargo.toml index 481f7bba..cf6bc632 100644 --- a/hives/claude-cli/Cargo.toml +++ b/hives/claude-cli/Cargo.toml @@ -10,6 +10,7 @@ name = "claude-cli-worker" path = "src/main.rs" [dependencies] +hum-paths = { path = "../../hum-paths" } nest = { path = "../../nest" } nest-common = { path = "../common" } tokio = { workspace = true, features = ["full"] } diff --git a/hives/claude-cli/src/main.rs b/hives/claude-cli/src/main.rs index 1c41aede..2f370da8 100644 --- a/hives/claude-cli/src/main.rs +++ b/hives/claude-cli/src/main.rs @@ -6,7 +6,7 @@ //! runtime via the `bee:["worker"]` hello. //! //! Env knobs: -//! HUM_THRUM_SOCK thrum socket (default `$XDG_RUNTIME_DIR/hum/thrum.sock`) +//! HUM_THRUM_SOCK thrum socket (default `$XDG_STATE_HOME/hum/thrum.sock`) //! CLAUDE_CLI_PATH claude binary (default `claude` on PATH) //! CLAUDE_MODELS comma-separated models advertised on hello //! (default: claude-opus-4-7,claude-sonnet-4-6,claude-haiku-4-5) @@ -19,6 +19,7 @@ use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> Result<()> { + hum_paths::init(); let filter = EnvFilter::try_from_env("HUM_LOG_LEVEL") .unwrap_or_else(|_| EnvFilter::new("info")); tracing_subscriber::fmt() diff --git a/hives/claude-repl/Cargo.toml b/hives/claude-repl/Cargo.toml index 44925ede..e9588658 100644 --- a/hives/claude-repl/Cargo.toml +++ b/hives/claude-repl/Cargo.toml @@ -10,6 +10,7 @@ name = "claude-repl-worker" path = "src/main.rs" [dependencies] +hum-paths = { path = "../../hum-paths" } nest = { path = "../../nest" } nest-common = { path = "../common" } tokio = { workspace = true, features = ["full"] } diff --git a/hives/claude-repl/src/main.rs b/hives/claude-repl/src/main.rs index ca70c6af..54613a51 100644 --- a/hives/claude-repl/src/main.rs +++ b/hives/claude-repl/src/main.rs @@ -10,6 +10,7 @@ use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> Result<()> { + hum_paths::init(); let filter = EnvFilter::try_from_env("HUM_LOG_LEVEL") .unwrap_or_else(|_| EnvFilter::new("info")); tracing_subscriber::fmt() diff --git a/hives/common/Cargo.toml b/hives/common/Cargo.toml index 274b1dc9..d502a8ff 100644 --- a/hives/common/Cargo.toml +++ b/hives/common/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true description = "Shared building blocks for nests — regex Classifier for the drone's context-loss heuristic, common spawn glue, bee identity helpers, MCP bridge for worker bees." [dependencies] +hum-paths = { path = "../../hum-paths" } drone = { path = "../../drone" } ensemble = { path = "../../ensemble" } mcp = { path = "../../mcp" } diff --git a/hives/common/src/forager.rs b/hives/common/src/forager.rs index ba6712fc..40c20615 100644 --- a/hives/common/src/forager.rs +++ b/hives/common/src/forager.rs @@ -114,23 +114,7 @@ impl Default for ForagerAdvert { } fn default_socket_path() -> PathBuf { - if let Ok(p) = std::env::var("HUM_THRUM_SOCK") { - return PathBuf::from(p); - } - if let Ok(p) = std::env::var("HUM_SOCKET") { - return PathBuf::from(p); - } - let runtime = std::env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(format!("/tmp/hum-{}", unsafe_uid()))); - runtime.join("hum").join("thrum.sock") -} - -fn unsafe_uid() -> u32 { - std::process::Command::new("id").arg("-u").output().ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .and_then(|s| s.trim().parse().ok()) - .unwrap_or(0) + hum_paths::thrum_sock() } /// Run the forager service loop. Blocks until shutdown; reconnects diff --git a/hives/common/src/identity.rs b/hives/common/src/identity.rs index 778dc5a6..3efc1bf7 100644 --- a/hives/common/src/identity.rs +++ b/hives/common/src/identity.rs @@ -37,24 +37,8 @@ impl BeeKey { } } -/// Resolve `$XDG_STATE_HOME/hum/bees/.key`. Falls back to -/// `$HOME/.local/state/hum/bees/.key`, then to a relative -/// `.local/state/hum/bees/.key` if neither env is set. pub fn bee_key_path(kind: &str) -> PathBuf { - let leaf = format!("{kind}.key"); - if let Ok(xdg) = std::env::var("XDG_STATE_HOME") { - if !xdg.is_empty() { - return PathBuf::from(xdg).join("hum").join("bees").join(leaf); - } - } - if let Ok(home) = std::env::var("HOME") { - if !home.is_empty() { - return PathBuf::from(home) - .join(".local").join("state").join("hum") - .join("bees").join(leaf); - } - } - PathBuf::from(format!(".local/state/hum/bees/{kind}.key")) + hum_paths::bee_key(kind) } /// Load the bee's persisted key, minting + persisting a fresh one diff --git a/hives/common/src/serve.rs b/hives/common/src/serve.rs index df654436..7ca7d44b 100644 --- a/hives/common/src/serve.rs +++ b/hives/common/src/serve.rs @@ -40,25 +40,8 @@ use tokio::sync::mpsc; use crate::identity::load_or_mint_bee_key; use crate::mcp_bridge::{spawn_local_mcp, McpBridge}; -/// Resolve the canonical thrum socket path. Mirrors `thrumd::default_socket_path`. fn default_socket_path() -> PathBuf { - if let Ok(p) = std::env::var("HUM_THRUM_SOCK") { - return PathBuf::from(p); - } - if let Ok(p) = std::env::var("HUM_SOCKET") { - return PathBuf::from(p); - } - let runtime = std::env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(format!("/tmp/hum-{}", unsafe_uid()))); - runtime.join("hum").join("thrum.sock") -} - -fn unsafe_uid() -> u32 { - std::process::Command::new("id").arg("-u").output().ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .and_then(|s| s.trim().parse().ok()) - .unwrap_or(0) + hum_paths::thrum_sock() } /// What the host advertises on hello. Drives both routing (humd maps diff --git a/hives/grpc/Cargo.toml b/hives/grpc/Cargo.toml index 252a75f8..bce33026 100644 --- a/hives/grpc/Cargo.toml +++ b/hives/grpc/Cargo.toml @@ -18,7 +18,7 @@ anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } async-stream = "0.3" -libc = "0.2" +hum-paths = { path = "../../hum-paths" } [build-dependencies] tonic-build = "0.12" diff --git a/hives/grpc/src/main.rs b/hives/grpc/src/main.rs index 6c91b7d2..b25db258 100644 --- a/hives/grpc/src/main.rs +++ b/hives/grpc/src/main.rs @@ -31,12 +31,7 @@ const HIVE_NAME: &str = "grpc"; const NESTLING_VERSION: &str = env!("CARGO_PKG_VERSION"); fn humd_sock_path() -> String { - if let Ok(s) = std::env::var("HUM_THRUM_SOCK") { - return s; - } - let runtime = std::env::var("XDG_RUNTIME_DIR") - .unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::geteuid() })); - format!("{runtime}/hum/thrum.sock") + hum_paths::thrum_sock().to_string_lossy().into_owned() } /// One bidi stream's bridge: open a thrum connection, pump tones both ways. @@ -161,6 +156,7 @@ impl Hum for HumBridge { #[tokio::main] async fn main() -> Result<()> { + hum_paths::init(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() diff --git a/hives/gsm-modem/Cargo.toml b/hives/gsm-modem/Cargo.toml index e5ee7096..334dff56 100644 --- a/hives/gsm-modem/Cargo.toml +++ b/hives/gsm-modem/Cargo.toml @@ -22,7 +22,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } -libc = "0.2" +hum-paths = { path = "../../hum-paths" } [[bin]] name = "gsm-modem" diff --git a/hives/gsm-modem/src/main.rs b/hives/gsm-modem/src/main.rs index faaa95a5..dcbb466d 100644 --- a/hives/gsm-modem/src/main.rs +++ b/hives/gsm-modem/src/main.rs @@ -54,9 +54,6 @@ struct Config { impl Config { fn from_env() -> Self { - let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| { - format!("/run/user/{}", unsafe { libc::geteuid() }) - }); Self { device: std::env::var("HUM_GSM_DEVICE").unwrap_or_else(|_| "/dev/ttyUSB0".into()), baud: std::env::var("HUM_GSM_BAUD") @@ -71,8 +68,7 @@ impl Config { .ok() .and_then(|s| s.parse().ok()) .unwrap_or(1500), - sock_path: std::env::var("HUM_THRUM_SOCK") - .unwrap_or_else(|_| format!("{runtime}/hum/thrum.sock")), + sock_path: hum_paths::thrum_sock().to_string_lossy().into_owned(), } } } @@ -231,6 +227,7 @@ async fn init_modem(writer: &Arc>) -> Result<()> { #[tokio::main] async fn main() -> Result<()> { + hum_paths::init(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() diff --git a/hives/humfs/Cargo.toml b/hives/humfs/Cargo.toml index d901bf6f..8c5abb7b 100644 --- a/hives/humfs/Cargo.toml +++ b/hives/humfs/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true description = "humfs — hum's native filesystem forager hive. Symbol-aware code surface (read/do_code/do_noncode/bash/task), AST-grounded via tree-sitter. Translates chi:tool-call ↔ filesystem ops; humd routes other foragers' tool-calls here." [dependencies] +hum-paths = { path = "../../hum-paths" } nest-common = { path = "../common" } thrum-core = { path = "../../thrum-core" } nest = { path = "../../nest" } diff --git a/hives/humfs/src/main.rs b/hives/humfs/src/main.rs index 2ee60535..970ab819 100644 --- a/hives/humfs/src/main.rs +++ b/hives/humfs/src/main.rs @@ -28,6 +28,7 @@ use dispatch::HumfsDispatcher; #[tokio::main] async fn main() -> Result<()> { + hum_paths::init(); tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_env("HUM_LOG_LEVEL") diff --git a/hives/ollama-server/Cargo.toml b/hives/ollama-server/Cargo.toml index 4724ba41..07904468 100644 --- a/hives/ollama-server/Cargo.toml +++ b/hives/ollama-server/Cargo.toml @@ -19,7 +19,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } chrono = { workspace = true } uuid = { version = "1", features = ["v4"] } -libc = "0.2" +hum-paths = { path = "../../hum-paths" } [[bin]] name = "ollama-server" diff --git a/hives/ollama-server/src/main.rs b/hives/ollama-server/src/main.rs index 83073185..831c9780 100644 --- a/hives/ollama-server/src/main.rs +++ b/hives/ollama-server/src/main.rs @@ -47,12 +47,7 @@ struct FileConfig { } fn config_file_path() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); - PathBuf::from(home) - .join(".config") - .join("hum") - .join("bees") - .join("ollama-server.json") + hum_paths::bee_config("ollama-server") } fn read_file_config() -> FileConfig { @@ -77,9 +72,6 @@ struct Config { impl Config { fn load() -> Self { let file = read_file_config(); - let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| { - format!("/run/user/{}", unsafe { libc::geteuid() }) - }); let port: u16 = match std::env::var("OLLAMA_SERVER_PORT") { Ok(s) => s.parse().unwrap_or(11434), Err(_) => file.port.unwrap_or(11434), @@ -103,8 +95,7 @@ impl Config { }), }; Self { - sock_path: std::env::var("HUM_THRUM_SOCK") - .unwrap_or_else(|_| format!("{runtime}/hum/thrum.sock")), + sock_path: hum_paths::thrum_sock().to_string_lossy().into_owned(), listen: format!("{host}:{port}"), models, bind: None, @@ -563,6 +554,7 @@ fn error_resp(code: StatusCode, msg: &str) -> Response { #[tokio::main] async fn main() -> Result<()> { + hum_paths::init(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() diff --git a/hives/openai-server/pnpm-lock.yaml b/hives/openai-server/pnpm-lock.yaml index 64cd4215..ed5a2890 100644 --- a/hives/openai-server/pnpm-lock.yaml +++ b/hives/openai-server/pnpm-lock.yaml @@ -13,7 +13,10 @@ importers: version: 22.19.19 tsup: specifier: ^8.5.1 - version: 8.5.1 + version: 8.5.1(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 packages: @@ -522,6 +525,11 @@ packages: typescript: optional: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -883,7 +891,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1: + tsup@8.5.1(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -902,12 +910,16 @@ snapshots: tinyexec: 0.3.2 tinyglobby: 0.2.16 tree-kill: 1.2.2 + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - jiti - supports-color - tsx - yaml + typescript@5.9.3: {} + ufo@1.6.4: {} undici-types@6.21.0: {} diff --git a/hives/paid-oracle/Cargo.toml b/hives/paid-oracle/Cargo.toml index df0b768f..c8bd9521 100644 --- a/hives/paid-oracle/Cargo.toml +++ b/hives/paid-oracle/Cargo.toml @@ -19,7 +19,7 @@ parking_lot = "0.12" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } uuid = { version = "1", features = ["v4"] } hex = { workspace = true } -libc = "0.2" +hum-paths = { path = "../../hum-paths" } [[bin]] name = "paid-oracle" diff --git a/hives/paid-oracle/src/main.rs b/hives/paid-oracle/src/main.rs index 17e058bb..d1622947 100644 --- a/hives/paid-oracle/src/main.rs +++ b/hives/paid-oracle/src/main.rs @@ -79,10 +79,6 @@ struct Config { impl Config { fn from_env() -> Result { - let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| { - format!("/run/user/{}", unsafe { libc::geteuid() }) - }); - let default_sock = format!("{runtime}/hum/thrum.sock"); let pay_kind = match std::env::var("PAID_ORACLE_PAY_KIND") .unwrap_or_else(|_| "native".into()) .to_ascii_lowercase() @@ -101,7 +97,7 @@ impl Config { PayKind::Erc20 => 6, }); Ok(Self { - sock_path: std::env::var("HUM_THRUM_SOCK").unwrap_or(default_sock), + sock_path: hum_paths::thrum_sock().to_string_lossy().into_owned(), pay_to: std::env::var("PAID_ORACLE_PAY_TO") .context("PAID_ORACLE_PAY_TO (your EVM address) is required")?, rpc_url: std::env::var("PAID_ORACLE_RPC") @@ -148,6 +144,7 @@ struct State { #[tokio::main] async fn main() -> Result<()> { + hum_paths::init(); tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))) diff --git a/hum-paths/Cargo.toml b/hum-paths/Cargo.toml new file mode 100644 index 00000000..389c2839 --- /dev/null +++ b/hum-paths/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "hum-paths" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Single source of truth for every on-disk path hum writes or reads." + +[dependencies] diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs new file mode 100644 index 00000000..5697a519 --- /dev/null +++ b/hum-paths/src/lib.rs @@ -0,0 +1,140 @@ +//! Single source of truth for every on-disk path hum reads or writes. +//! +//! Call `init()` once at process startup before any other call here. +//! It sets any unset XDG env vars to HOME-relative defaults, so every +//! subsequent call in the process resolves without fallback logic. +//! +//! Layout follows the XDG Base Directory spec. + +use std::path::PathBuf; + +/// Set unset XDG env vars to HOME-relative defaults. +/// +/// Must be called once at startup in humd, hum CLI, and every hive worker. +/// Panics if `HOME` is unset, which is always a configuration error. +pub fn init() { + let home = home(); + xdg_default("XDG_STATE_HOME", home.join(".local/state")); + xdg_default("XDG_CONFIG_HOME", home.join(".config")); + xdg_default("XDG_DATA_HOME", home.join(".local/share")); + xdg_default("XDG_CACHE_HOME", home.join(".cache")); + xdg_default("XDG_RUNTIME_DIR", home.join(".local/state/run")); +} + +fn xdg_default(var: &str, default: PathBuf) { + if std::env::var_os(var).is_none() { + // Safety: single-threaded startup; no other threads reading env yet. + unsafe { std::env::set_var(var, default); } + } +} + +// ── Directory roots ────────────────────────────────────────────────────────── + +/// `$XDG_STATE_HOME/hum` — persistent state: keys, snapshots, drift rings, +/// the thrum socket, and the rendezvous file. +pub fn state_dir() -> PathBuf { + xdg("XDG_STATE_HOME").join("hum") +} + +/// `$XDG_CONFIG_HOME/hum` — user-editable config: hum.json, peers.json. +pub fn config_dir() -> PathBuf { + xdg("XDG_CONFIG_HOME").join("hum") +} + +/// `$XDG_DATA_HOME/hum` — installed source clone, recipes. +pub fn data_dir() -> PathBuf { + xdg("XDG_DATA_HOME").join("hum") +} + +/// `$XDG_CACHE_HOME/hum` — derived caches (e.g. foreign hive clones). +pub fn cache_dir() -> PathBuf { + xdg("XDG_CACHE_HOME").join("hum") +} + +/// `$XDG_RUNTIME_DIR/hum` — non-essential per-boot runtime files. +pub fn runtime_dir() -> PathBuf { + xdg("XDG_RUNTIME_DIR").join("hum") +} + +// ── Named files ────────────────────────────────────────────────────────────── + +/// Thrum socket: `$XDG_STATE_HOME/hum/thrum.sock`. +/// Respects `HUM_THRUM_SOCK` and the legacy `HUM_SOCKET` override. +pub fn thrum_sock() -> PathBuf { + if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { return PathBuf::from(p); } + if let Some(p) = std::env::var_os("HUM_SOCKET") { return PathBuf::from(p); } + state_dir().join("thrum.sock") +} + +/// humd HTTP control socket. +pub fn http_sock() -> PathBuf { runtime_dir().join("hum.sock.http") } + +/// Penny lifetime counters. +pub fn penny() -> PathBuf { runtime_dir().join("penny.json") } + +/// humd ed25519 identity seed. +pub fn humd_key() -> PathBuf { state_dir().join("humd.key") } + +/// Directory holding per-bee ed25519 identity seeds. +pub fn bees_dir() -> PathBuf { state_dir().join("bees") } + +/// Per-bee ed25519 identity seed; one file per hive kind. +pub fn bee_key(kind: &str) -> PathBuf { + bees_dir().join(format!("{kind}.key")) +} + +/// Live bee manifest snapshot (written by daemon on every register/disconnect). +pub fn bees_snapshot() -> PathBuf { state_dir().join("bees.json") } + +/// Rendezvous file: running daemon publishes its socket path, pid, and version here. +pub fn runtime_info() -> PathBuf { state_dir().join("runtime.json") } + +/// `hum.json` (daemon policy). +pub fn hum_json() -> PathBuf { config_dir().join("hum.json") } + +/// `peers.json` (ensemble peer list). +pub fn peers_json() -> PathBuf { config_dir().join("peers.json") } + +/// Drift rings directory (`drift/YYYY-MM-DD.ndjson`). +pub fn drift_dir() -> PathBuf { state_dir().join("drift") } + +/// Cloned hum source tree (recipes + svc.sh helpers). +pub fn src_dir() -> PathBuf { data_dir().join("src") } + +/// Installed humd binary location. +pub fn humd_bin() -> PathBuf { + home().join(".local/bin/humd") +} + +/// hums.json (session registry). +pub fn hums_json() -> PathBuf { state_dir().join("hums.json") } + +/// Per-bee config file for a given hive kind (e.g. `ollama-server.json`). +pub fn bee_config(kind: &str) -> PathBuf { + config_dir().join("bees").join(format!("{kind}.json")) +} + +/// macOS log paths for a launchd unit short id (e.g. `"hum"`, `"hum-claude-cli-worker"`). +/// Returns `(stdout, stderr)`. +pub fn macos_log(unit: &str) -> (PathBuf, PathBuf) { + let base = home().join("Library/Logs"); + ( + base.join(format!("sh.hum.{unit}.out.log")), + base.join(format!("sh.hum.{unit}.err.log")), + ) +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn xdg(var: &str) -> PathBuf { + PathBuf::from( + std::env::var_os(var) + .unwrap_or_else(|| panic!("{var} not set — call hum_paths::init() at process startup")), + ) +} + +fn home() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .expect("HOME must be set") +} diff --git a/hum/Cargo.toml b/hum/Cargo.toml index f0fff674..a6b4f885 100644 --- a/hum/Cargo.toml +++ b/hum/Cargo.toml @@ -8,6 +8,7 @@ name = "hum" path = "src/main.rs" [dependencies] +hum-paths = { path = "../hum-paths" } humd = { path = "../humd" } config = { path = "../config" } anyhow = "1" diff --git a/hum/src/main.rs b/hum/src/main.rs index cd2a7863..327e7f98 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -97,6 +97,7 @@ enum Cmd { } fn main() -> Result<()> { + hum_paths::init(); let cli = Cli::parse(); match cli.cmd { None => summary(), @@ -177,35 +178,10 @@ fn home() -> Result { std::env::var_os("HOME").map(PathBuf::from).context("HOME unset") } -fn xdg(var: &str, default_suffix: &str) -> Result { - if let Some(v) = std::env::var_os(var) { - return Ok(PathBuf::from(v)); - } - Ok(home()?.join(default_suffix)) -} - -fn xdg_runtime_hum() -> PathBuf { - std::env::var_os("XDG_RUNTIME_DIR") - .map(|v| PathBuf::from(v).join("hum")) - .unwrap_or_else(|| PathBuf::from(format!("/tmp/hum-{}", libc_getuid()))) -} - -fn unsafe_libc_getuid_fallback() -> u32 { 0 } -fn libc_getuid() -> u32 { - // Avoid pulling in the libc crate — shell out to `id -u`. Cheap; - // only called when XDG_RUNTIME_DIR isn't set. - Command::new("id").arg("-u").output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .and_then(|s| s.trim().parse().ok()) - .unwrap_or_else(unsafe_libc_getuid_fallback) -} - fn humd_bin() -> Result { - // Convention: $HUM_DATA/bin/humd or $HOME/.local/bin/humd. let candidates = [ std::env::var_os("HUM_BIN").map(PathBuf::from), - home().ok().map(|h| h.join(".local").join("bin").join("humd")), + Some(hum_paths::humd_bin()), ]; for c in candidates.into_iter().flatten() { if c.exists() { return Ok(c); } @@ -219,7 +195,7 @@ fn svc_helper() -> Option { std::env::current_exe().ok() .and_then(|p| p.parent().map(|p| p.to_path_buf())) .map(|p| p.join("../../scripts/svc.sh")), - home().ok().map(|h| h.join(".local/share/hum/src/scripts/svc.sh")), + Some(hum_paths::src_dir().join("scripts/svc.sh")), Some(PathBuf::from("./scripts/svc.sh")), ]; candidates.into_iter().flatten().find(|p| p.exists()) @@ -233,12 +209,7 @@ fn summary() -> Result<()> { } fn status() -> Result<()> { - let cfg = xdg("XDG_CONFIG_HOME", ".config")?.join("hum"); - let state = xdg("XDG_STATE_HOME", ".local/state")?.join("hum"); - let runtime = xdg_runtime_hum(); - let thrum_sock = std::env::var_os("HUM_THRUM_SOCK") - .map(PathBuf::from) - .unwrap_or_else(|| runtime.join("thrum.sock")); + let thrum_sock = hum_paths::thrum_sock(); let bin = humd_bin().ok(); let bin_display = bin.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "(missing)".into()); @@ -252,12 +223,12 @@ fn status() -> Result<()> { .unwrap_or_else(|| "?".into()); println!(" version: {v}"); } - println!("identity: {} {}", state.join("humd.key").display(), - yn(state.join("humd.key").exists())); - println!("peers.json: {} {}", cfg.join("peers.json").display(), - yn(cfg.join("peers.json").exists())); - println!("hum.json: {} {}", cfg.join("hum.json").display(), - yn(cfg.join("hum.json").exists())); + let humd_key = hum_paths::humd_key(); + let peers_json = hum_paths::peers_json(); + let hum_json = hum_paths::hum_json(); + println!("identity: {} {}", humd_key.display(), yn(humd_key.exists())); + println!("peers.json: {} {}", peers_json.display(), yn(peers_json.exists())); + println!("hum.json: {} {}", hum_json.display(), yn(hum_json.exists())); println!("thrum socket: {} {}", thrum_sock.display(), yn(std::fs::metadata(&thrum_sock).is_ok())); @@ -306,18 +277,19 @@ fn doctor() -> Result<()> { println!(" os: {} {}", std::env::consts::OS, std::env::consts::ARCH); // 2. Config + state files. - let cfg = xdg("XDG_CONFIG_HOME", ".config")?.join("hum"); - let state = xdg("XDG_STATE_HOME", ".local/state")?.join("hum"); + let hum_json = hum_paths::hum_json(); + let peers_json = hum_paths::peers_json(); + let humd_key = hum_paths::humd_key(); println!("\n[config + state]"); - println!(" hum.json: {} {}", cfg.join("hum.json").display(), yn(cfg.join("hum.json").exists())); - println!(" peers.json: {} {}", cfg.join("peers.json").display(), yn(cfg.join("peers.json").exists())); - println!(" identity: {} {}", state.join("humd.key").display(), yn(state.join("humd.key").exists())); + println!(" hum.json: {} {}", hum_json.display(), yn(hum_json.exists())); + println!(" peers.json: {} {}", peers_json.display(), yn(peers_json.exists())); + println!(" identity: {} {}", humd_key.display(), yn(humd_key.exists())); // 3. hum.json lint — catches the config drift that silently breaks // routing (the keys humd ignores, stale section names, a default // pointing nowhere). These parse fine but do nothing. println!("\n[hum.json schema validation]"); - match std::fs::read_to_string(cfg.join("hum.json")) { + match std::fs::read_to_string(&hum_json) { Err(_) => println!(" (no hum.json — humd runs on defaults)"), Ok(raw) => match config::validate(&raw) { Ok(()) => println!(" ✓ valid against hum.schema.json"), @@ -331,8 +303,8 @@ fn doctor() -> Result<()> { // 4. Bee identities — the persisted keys that back hid dedup. A // missing or wrong-size key means a bee can't keep a stable hid // across reconnects (ghost-manifest accumulation). - println!("\n[bee identities] ({}/bees)", state.display()); - let bees_dir = state.join("bees"); + let bees_dir = hum_paths::bees_dir(); + println!("\n[bee identities] ({})", bees_dir.display()); match std::fs::read_dir(&bees_dir) { Err(_) => println!(" (none yet — minted on first bee boot)"), Ok(entries) => { @@ -353,14 +325,9 @@ fn doctor() -> Result<()> { // 5. Env sanity — the macOS traps live here. println!("\n[env sanity]"); let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default(); - if runtime.is_empty() { - println!(" XDG_RUNTIME_DIR: (unset) — humd falls back to /tmp/hum-"); - } else { - let exists = std::path::Path::new(&runtime).is_dir(); - println!(" XDG_RUNTIME_DIR: {runtime} {}", if exists { "✓" } else { "✗ DOES NOT EXIST (penny/socket writes will fail — common macOS trap when set to a Linux /run/user path)" }); - } - let sock = std::env::var_os("HUM_THRUM_SOCK").map(PathBuf::from) - .unwrap_or_else(|| xdg_runtime_hum().join("thrum.sock")); + let runtime_exists = std::path::Path::new(&runtime).is_dir(); + println!(" XDG_RUNTIME_DIR: {runtime} {}", if runtime_exists { "✓" } else { "✗ DOES NOT EXIST (penny writes will fail — common macOS trap when set to a Linux /run/user path)" }); + let sock = hum_paths::thrum_sock(); println!(" thrum sock: {} {}", sock.display(), if std::fs::metadata(&sock).is_ok() { "✓ present" } else { "✗ MISSING (humd not running?)" }); // 4. The claude binary (worker's compute). @@ -481,7 +448,7 @@ fn hive_list() -> Result<()> { } } } - let hum_json = xdg("XDG_CONFIG_HOME", ".config")?.join("hum").join("hum.json"); + let hum_json = hum_paths::hum_json(); let mut default_kind = String::new(); if let Ok(raw) = std::fs::read_to_string(&hum_json) { if let Ok(v) = serde_json::from_str::(&raw) { @@ -556,8 +523,8 @@ fn resolve_hive_install(reference: &str) -> Result { return Ok(repo_root_or_install_dir().join(sub).join("install")); } // Foreign repo → shallow clone into a cache, then the subpath. - let cache = xdg("XDG_CACHE_HOME", ".cache")? - .join("hum").join("hives").join(format!("{org}-{repo}-{branch}")); + let cache = hum_paths::cache_dir() + .join("hives").join(format!("{org}-{repo}-{branch}")); if !cache.exists() { std::fs::create_dir_all(cache.parent().unwrap()).ok(); let url = format!("https://github.com/{org}/{repo}"); @@ -586,7 +553,7 @@ fn resolve_hive_install(reference: &str) -> Result { /// (hid, role, models, tools, provides, wire, version, source) joined /// with each bee's service unit + running state. fn bee_list_full(svc: &std::path::Path, installed: &[String]) -> Result<()> { - let snap_path = xdg("XDG_STATE_HOME", ".local/state")?.join("hum").join("bees.json"); + let snap_path = hum_paths::bees_snapshot(); let live: Vec = std::fs::read_to_string(&snap_path).ok() .and_then(|s| serde_json::from_str::(&s).ok()) .and_then(|v| v.as_object().map(|o| o.values().cloned().collect())) @@ -693,7 +660,7 @@ fn bee(target: Option, verb: Option, list: bool) -> Result<()> { } fn penny() -> Result<()> { - let path = xdg("XDG_STATE_HOME", ".local/state")?.join("hum").join("penny.json"); + let path = hum_paths::penny(); if !path.exists() { println!("no penny.json yet ({})", path.display()); return Ok(()); @@ -747,8 +714,8 @@ fn repo_root_or_install_dir() -> PathBuf { p = parent.to_path_buf(); } } - if let Ok(h) = home() { - let candidate = h.join(".local/share/hum/src"); + { + let candidate = hum_paths::src_dir(); if candidate.exists() { return candidate; } } PathBuf::from(".") diff --git a/humd/Cargo.toml b/humd/Cargo.toml index 9e944eee..0a22337e 100644 --- a/humd/Cargo.toml +++ b/humd/Cargo.toml @@ -13,6 +13,7 @@ name = "humd" path = "src/main.rs" [dependencies] +hum-paths = { path = "../hum-paths" } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/humd/examples/smoke.rs b/humd/examples/smoke.rs index 6bd675b8..d5045f55 100644 --- a/humd/examples/smoke.rs +++ b/humd/examples/smoke.rs @@ -6,8 +6,8 @@ //! //! Run: `cargo run --example smoke -p humd-bin` //! -//! Socket path: `$HUM_THRUM_SOCK` if set, else `$XDG_RUNTIME_DIR/hum/thrum.sock`, -//! else `/tmp/hum/thrum.sock`. Legacy `HUM_SOCKET` also accepted. +//! Socket path: `$HUM_THRUM_SOCK` if set, else `$XDG_STATE_HOME/hum/thrum.sock`. +//! Legacy `HUM_SOCKET` also accepted. use std::path::PathBuf; use std::process::ExitCode; @@ -23,20 +23,12 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); const READ_TIMEOUT: Duration = Duration::from_secs(5); fn socket_path() -> PathBuf { - if let Ok(p) = std::env::var("HUM_THRUM_SOCK") { - return PathBuf::from(p); - } - if let Ok(p) = std::env::var("HUM_SOCKET") { - return PathBuf::from(p); - } - let base = std::env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")); - base.join("hum").join("thrum.sock") + hum_paths::thrum_sock() } #[tokio::main(flavor = "current_thread")] async fn main() -> ExitCode { + hum_paths::init(); let path = socket_path(); let stream = match timeout(CONNECT_TIMEOUT, UnixStream::connect(&path)).await { diff --git a/humd/src/identity.rs b/humd/src/identity.rs index bef1bcf3..1de7483c 100644 --- a/humd/src/identity.rs +++ b/humd/src/identity.rs @@ -26,24 +26,8 @@ use ensemble::HumdKey; use rand::RngCore; use tracing::{info, trace}; -/// `$XDG_STATE_HOME/hum/humd.key`. Falls back to `$HOME/.local/state/hum/humd.key`, -/// then `.local/state/hum/humd.key` if neither env var is set. pub fn key_path() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_STATE_HOME") { - if !xdg.is_empty() { - return PathBuf::from(xdg).join("hum").join("humd.key"); - } - } - if let Ok(home) = std::env::var("HOME") { - if !home.is_empty() { - return PathBuf::from(home) - .join(".local") - .join("state") - .join("hum") - .join("humd.key"); - } - } - PathBuf::from(".local/state/hum/humd.key") + hum_paths::humd_key() } /// Load the persisted key, minting + persisting a fresh one on first boot. diff --git a/humd/src/lib.rs b/humd/src/lib.rs index 4e2a36dc..3abf8f1e 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -80,12 +80,11 @@ pub struct DaemonConfig { impl DaemonConfig { pub fn from_env() -> Self { - let runtime_dir = runtime_dir(); // Canonical thrum socket path — honors HUM_THRUM_SOCK (and the // legacy HUM_SOCKET fallback). thrumd owns the source of truth; // humd just reuses it so binary + protocol agree. let thrum_path = thrumd::default_socket_path(); - let http_path = runtime_dir.join("hum.sock.http"); + let http_path = hum_paths::http_sock(); let mcp_port: u16 = std::env::var("HUM_MCP_PORT") .ok() .and_then(|s| s.parse().ok()) @@ -104,7 +103,7 @@ impl DaemonConfig { thrum_path, http_path, mcp_addr: ([127, 0, 0, 1], mcp_port).into(), - penny_path: runtime_dir.join("penny.json"), + penny_path: hum_paths::penny(), hum_cfg: config::load(), cli_path: std::env::var("CLAUDE_CLI_PATH").unwrap_or_else(|_| "claude".into()), penny_persist_interval: Duration::from_secs(10), @@ -119,12 +118,6 @@ impl DaemonConfig { } } -fn runtime_dir() -> PathBuf { - let base = std::env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")); - base.join("hum") -} // ── Public entry point ───────────────────────────────────────────────────── @@ -151,7 +144,7 @@ where penny.clone().spawn_persister(cfg.penny_path.clone(), cfg.penny_persist_interval); let waneman = cfg.waneman.clone().unwrap_or_else(|| Arc::new(WaneTracker::new())); - let _drift = drift::Drift::new(); + let _drift = drift::Drift::with_store_dir(hum_paths::drift_dir()); let _drone = drone::Drone::new(); // Bring up an Ensemble from the persisted identity when the caller @@ -478,14 +471,8 @@ impl ensemble::AliasResolver for PeersAliasResolver { /// routing decisions humd makes locally. type Manifests = Arc>>; -/// `$XDG_STATE_HOME/hum/bees.json` (else `~/.local/state/hum/bees.json`). -/// The `hum` CLI reads this to render `hum bee --list` with full manifest -/// info. Same resolution as the CLI's state-dir logic. fn bees_snapshot_path() -> std::path::PathBuf { - let base = std::env::var_os("XDG_STATE_HOME").map(std::path::PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local").join("state"))) - .unwrap_or_else(|| std::path::PathBuf::from(".local/state")); - base.join("hum").join("bees.json") + hum_paths::bees_snapshot() } // humd doesn't host an MCP server — tool-call routing is purely diff --git a/humd/src/main.rs b/humd/src/main.rs index bae94823..8aa8a5bf 100644 --- a/humd/src/main.rs +++ b/humd/src/main.rs @@ -49,6 +49,7 @@ async fn main() -> Result<()> { } } + hum_paths::init(); let filter = EnvFilter::try_from_env("HUM_LOG_LEVEL") .unwrap_or_else(|_| EnvFilter::new("trace")); tracing_subscriber::fmt() diff --git a/humd/src/peers.rs b/humd/src/peers.rs index 8ed47a64..234e25b7 100644 --- a/humd/src/peers.rs +++ b/humd/src/peers.rs @@ -52,22 +52,8 @@ struct RawPeer { alias: Option, } -/// Resolve `${XDG_CONFIG_HOME or $HOME/.config}/hum/peers.json`. pub fn peers_path() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - if !xdg.is_empty() { - return PathBuf::from(xdg).join("hum").join("peers.json"); - } - } - if let Ok(home) = std::env::var("HOME") { - if !home.is_empty() { - return PathBuf::from(home) - .join(".config") - .join("hum") - .join("peers.json"); - } - } - PathBuf::from(".config/hum/peers.json") + hum_paths::peers_json() } /// Best-effort load of the peers file. diff --git a/hums/Cargo.toml b/hums/Cargo.toml index f4ae0fef..8077404e 100644 --- a/hums/Cargo.toml +++ b/hums/Cargo.toml @@ -6,8 +6,8 @@ license.workspace = true description = "Hum state persistence — registry of sessions, atomic JSON load/save under XDG_STATE_HOME." [dependencies] +hum-paths = { path = "../hum-paths" } serde = { workspace = true } serde_json = { workspace = true } parking_lot = "0.12" -directories = "5" tracing = { workspace = true } diff --git a/hums/src/lib.rs b/hums/src/lib.rs index d26ccb9c..6747aa3f 100644 --- a/hums/src/lib.rs +++ b/hums/src/lib.rs @@ -107,27 +107,13 @@ pub struct Hums { } impl Hums { - /// State directory: `${XDG_STATE_HOME or HOME/.local/state}/hum`. pub fn state_dir() -> PathBuf { - if let Ok(p) = std::env::var("XDG_STATE_HOME") { - return PathBuf::from(p).join("hum"); - } - if let Some(p) = directories::ProjectDirs::from("", "", "hum") { - // ProjectDirs.state_dir() is only populated on Linux; fall back - // to ~/.local/state/hum elsewhere via home_dir below. - if let Some(s) = p.state_dir() { - return s.to_path_buf(); - } - } - let home = directories::BaseDirs::new() - .map(|b| b.home_dir().to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")); - home.join(".local/state/hum") + hum_paths::state_dir() } /// Default file path: `/hums.json`. pub fn default_file() -> PathBuf { - Self::state_dir().join("hums.json") + hum_paths::hums_json() } /// Load the registry from the default path, applying legacy backfill. diff --git a/thrum-clients/go/thrum/helpers.go b/thrum-clients/go/thrum/helpers.go index 8ecd118f..69d561b0 100644 --- a/thrum-clients/go/thrum/helpers.go +++ b/thrum-clients/go/thrum/helpers.go @@ -100,14 +100,15 @@ func (w *WaneTracker) Behind(sigil string, remote int64) bool { } // DefaultSocketPath resolves the humd thrum socket per WIRE.md priority: -// HUM_THRUM_SOCK > $XDG_RUNTIME_DIR/hum/thrum.sock > /run/user//hum/thrum.sock. +// HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/thrum.sock > ~/.local/state/hum/thrum.sock. func DefaultSocketPath() string { if explicit := os.Getenv("HUM_THRUM_SOCK"); explicit != "" { return explicit } - runtime := os.Getenv("XDG_RUNTIME_DIR") - if runtime == "" { - runtime = fmt.Sprintf("/run/user/%d", os.Geteuid()) + state := os.Getenv("XDG_STATE_HOME") + if state == "" { + home, _ := os.UserHomeDir() + state = filepath.Join(home, ".local", "state") } - return filepath.Join(runtime, "hum", "thrum.sock") + return filepath.Join(state, "hum", "thrum.sock") } diff --git a/thrum-clients/python/thrum/helpers.py b/thrum-clients/python/thrum/helpers.py index 048fe160..fa6bdc02 100644 --- a/thrum-clients/python/thrum/helpers.py +++ b/thrum-clients/python/thrum/helpers.py @@ -92,9 +92,9 @@ def behind(self, sigil: str, remote: int) -> bool: def default_socket_path() -> str: """Resolve the humd thrum socket per WIRE.md priority: - HUM_THRUM_SOCK > $XDG_RUNTIME_DIR/hum/thrum.sock > /run/user//hum/thrum.sock.""" + HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/thrum.sock > ~/.local/state/hum/thrum.sock.""" explicit = os.environ.get("HUM_THRUM_SOCK") if explicit: return explicit - runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.geteuid()}" - return os.path.join(runtime, "hum", "thrum.sock") + state = os.environ.get("XDG_STATE_HOME") or os.path.join(os.path.expanduser("~"), ".local", "state") + return os.path.join(state, "hum", "thrum.sock") diff --git a/thrumd/Cargo.toml b/thrumd/Cargo.toml index 70141415..09f088dc 100644 --- a/thrumd/Cargo.toml +++ b/thrumd/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true description = "NDJSON unix-socket thrum server — listens, dispatches, broadcasts." [dependencies] +hum-paths = { path = "../hum-paths" } thrum-core = { path = "../thrum-core" } tokio = { workspace = true } serde = { workspace = true } diff --git a/thrumd/src/lib.rs b/thrumd/src/lib.rs index 9213ad99..c52863d9 100644 --- a/thrumd/src/lib.rs +++ b/thrumd/src/lib.rs @@ -47,24 +47,9 @@ pub trait ToneSink: Send + Sync + 'static { async fn forget(&self, _client_id: &str) {} } -/// Default socket path — `$XDG_RUNTIME_DIR/hum/thrum.sock`, or -/// `/tmp/hum/thrum.sock` if XDG_RUNTIME_DIR isn't set. -/// -/// Canonical per `WIRE.md`. Env override: `HUM_THRUM_SOCK`. Legacy -/// `HUM_SOCKET` is also accepted so an in-flight upgrade doesn't break -/// already-running clients pointing at the old name — drop the -/// fallback after 0.4. +/// Canonical thrum socket path. Delegates to `hum_paths::thrum_sock()`. pub fn default_socket_path() -> PathBuf { - if let Ok(p) = std::env::var("HUM_THRUM_SOCK") { - return PathBuf::from(p); - } - if let Ok(p) = std::env::var("HUM_SOCKET") { - return PathBuf::from(p); - } - let base = std::env::var("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("/tmp")); - base.join("hum").join("thrum.sock") + hum_paths::thrum_sock() } /// The thrum's living state — registry plus optional handler. Cloning is From e351b9c0804f5b0c5b644392b2026896e0a95329 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 15:56:17 +0000 Subject: [PATCH 02/18] nest: own process lifecycle; fix #41 + pay surrounding debt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #41 root cause was Arc>> + try_lock in claude-cli — wait task held the lock forever, kill closure's try_lock always failed, SIGKILL never reached the child. - nest::lifecycle::supervise(AsyncGroupChild) using tokio::select! over child.wait() vs CancellationToken. command-group for tree-kill so claude's descendants die too. Reaps with timeout. - Cell.kill: Arc -> Cell.cancel: CancellationToken. - Drop on CellBundle cancels on map removal; idle reaper + LRU evict + map clear all kill correctly. - claude-cli + claude-repl + mock + nest::pool + serve.rs migrated. - lru::LruCache replaces HashMap + linear-scan eviction (O(1)). - nest::metrics swapped /proc parser for sysinfo (cross-platform RSS/CPU). - metrics + metrics-exporter-prometheus on humd; /metrics on 127.0.0.1:9909 (HUM_METRICS_ADDR override). Counters at evict + kill sites; gauge for active cells. - governor token bucket on thrumd accept loop (100/s). - Reconnect jitter on serve_worker reconnect to spread thundering herds. - 3 lifecycle tests: cancel kills, natural exit propagates, tree-kill takes grandchild. --- Cargo.lock | 301 +++++++++++++++++++++++++++++++++-- hives/claude-cli/Cargo.toml | 1 + hives/claude-cli/src/lib.rs | 44 ++--- hives/claude-repl/Cargo.toml | 1 + hives/claude-repl/src/lib.rs | 20 +-- hives/common/Cargo.toml | 3 + hives/common/src/serve.rs | 58 +++---- humd/Cargo.toml | 2 + humd/src/lib.rs | 16 ++ nest/Cargo.toml | 8 + nest/src/lib.rs | 6 +- nest/src/lifecycle.rs | 108 +++++++++++++ nest/src/metrics.rs | 183 ++++----------------- nest/src/mock.rs | 4 +- nest/src/pool.rs | 8 +- thrumd/Cargo.toml | 1 + thrumd/src/lib.rs | 4 + 17 files changed, 518 insertions(+), 250 deletions(-) create mode 100644 nest/src/lifecycle.rs diff --git a/Cargo.lock b/Cargo.lock index 9d26945e..af6e641a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -569,6 +569,7 @@ version = "0.31.18" dependencies = [ "anyhow", "async-trait", + "command-group", "hum-paths", "nest", "nest-common", @@ -591,6 +592,7 @@ dependencies = [ "portable-pty", "serde_json", "tokio", + "tokio-util", "tracing", "tracing-subscriber", ] @@ -643,6 +645,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "command-group" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68fa787550392a9d58f44c21a3022cfb3ea3e2458b7f85d3b399d0ceeccf409" +dependencies = [ + "async-trait", + "nix 0.27.1", + "tokio", + "winapi", +] + [[package]] name = "config" version = "0.31.18" @@ -897,6 +911,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -1443,6 +1470,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" @@ -1472,7 +1505,7 @@ dependencies = [ "log", "rustversion", "windows-link", - "windows-result", + "windows-result 0.4.1", ] [[package]] @@ -1550,6 +1583,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "governor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b" +dependencies = [ + "cfg-if", + "dashmap", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.8.6", + "smallvec", + "spinning_top", +] + [[package]] name = "grpc-forager" version = "0.31.18" @@ -1624,6 +1677,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1663,6 +1725,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1836,6 +1904,8 @@ dependencies = [ "hums", "ids", "mcp", + "metrics", + "metrics-exporter-prometheus", "nest", "parking_lot", "penny", @@ -1984,7 +2054,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2204,7 +2274,7 @@ dependencies = [ "socket2 0.6.3", "widestring", "windows-registry", - "windows-result", + "windows-result 0.4.1", "windows-sys 0.61.2", ] @@ -2641,6 +2711,51 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "metrics" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045b4193fbdc5b5681f32f11070da9be3609f189a79f3390706d42587f46bb5" +dependencies = [ + "ahash", + "portable-atomic", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f0c8427b39666bf970460908b213ec09b3b350f20c0c2eabcbba51704a08e6" +dependencies = [ + "base64", + "http-body-util", + "hyper", + "hyper-util", + "indexmap 2.14.0", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4259040465c955f9f2f1a4a8a16dc46726169bca0f88e8fb2dbeced487c3e828" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.14.5", + "metrics", + "num_cpus", + "quanta", + "sketches-ddsketch", +] + [[package]] name = "micromap" version = "0.3.0" @@ -2785,13 +2900,18 @@ version = "0.31.18" dependencies = [ "anyhow", "async-trait", + "command-group", "libc", + "lru 0.12.5", + "metrics", "parking_lot", "portable-pty", "serde", "serde_json", + "sysinfo", "thiserror 1.0.69", "tokio", + "tokio-util", "tracing", ] @@ -2807,7 +2927,9 @@ dependencies = [ "ensemble", "futures", "hum-paths", + "lru 0.12.5", "mcp", + "metrics", "nest", "parking_lot", "rand 0.8.6", @@ -2817,6 +2939,7 @@ dependencies = [ "tempfile", "thrum-core", "tokio", + "tokio-util", "tracing", "uuid", ] @@ -2936,8 +3059,8 @@ dependencies = [ "tokio-util", "tracing", "web-sys", - "windows", - "windows-result", + "windows 0.62.2", + "windows-result 0.4.1", "wmi", ] @@ -2952,6 +3075,17 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.28.0" @@ -2976,6 +3110,18 @@ dependencies = [ "libc", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "noq" version = "1.0.0-rc.0" @@ -3038,6 +3184,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3132,6 +3287,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -3698,6 +3863,21 @@ dependencies = [ "prost", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -3868,6 +4048,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -4535,6 +4724,12 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "sketches-ddsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" + [[package]] name = "slab" version = "0.4.12" @@ -4590,6 +4785,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -4770,6 +4974,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows 0.57.0", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -4879,6 +5096,7 @@ version = "0.31.18" dependencies = [ "anyhow", "async-trait", + "governor", "hum-paths", "parking_lot", "serde", @@ -5710,6 +5928,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets", +] + [[package]] name = "windows" version = "0.62.2" @@ -5717,7 +5945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.62.2", "windows-future", "windows-numerics", ] @@ -5728,7 +5956,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets", ] [[package]] @@ -5737,10 +5977,10 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] @@ -5750,11 +5990,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5766,6 +6017,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5789,7 +6051,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", ] @@ -5800,10 +6062,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -6036,8 +6307,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows", - "windows-core", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] diff --git a/hives/claude-cli/Cargo.toml b/hives/claude-cli/Cargo.toml index cf6bc632..efe90e3d 100644 --- a/hives/claude-cli/Cargo.toml +++ b/hives/claude-cli/Cargo.toml @@ -14,6 +14,7 @@ hum-paths = { path = "../../hum-paths" } nest = { path = "../../nest" } nest-common = { path = "../common" } tokio = { workspace = true, features = ["full"] } +command-group = { version = "5", features = ["with-tokio"] } serde_json = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/hives/claude-cli/src/lib.rs b/hives/claude-cli/src/lib.rs index caa9d473..b622733c 100644 --- a/hives/claude-cli/src/lib.rs +++ b/hives/claude-cli/src/lib.rs @@ -13,8 +13,9 @@ use anyhow::{Context, Result}; use async_trait::async_trait; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use command_group::AsyncCommandGroup; use tokio::process::Command; -use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::sync::{mpsc, Mutex}; use tracing::{trace, warn}; use nest::{Propensity, Cell, SpawnSpec, WorkerBee}; @@ -127,18 +128,16 @@ impl WorkerBee for ClaudeCliWorker { .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); + .stderr(Stdio::piped()); - let mut child = cmd.spawn().with_context(|| format!("spawn {cli}"))?; - let pid = child.id(); - let mut stdin = child.stdin.take().context("missing stdin")?; - let stdout = child.stdout.take().context("missing stdout")?; - let stderr = child.stderr.take().context("missing stderr")?; + let mut child = cmd.group_spawn().with_context(|| format!("spawn {cli}"))?; + let pid = child.inner().id(); + let mut stdin = child.inner().stdin.take().context("missing stdin")?; + let stdout = child.inner().stdout.take().context("missing stdout")?; + let stderr = child.inner().stderr.take().context("missing stderr")?; let (tx_in, mut rx_in) = mpsc::channel::(64); let (tx_evt, rx_evt) = mpsc::channel::(256); - let (tx_exit, rx_exit) = oneshot::channel::(); // stdin pump — append `\n` for NDJSON framing. tokio::spawn(async move { @@ -196,30 +195,7 @@ impl WorkerBee for ClaudeCliWorker { } }); - // exit watcher — keep Child alive behind an async-safe mutex so - // both the wait task and the kill closure can reach it. - let kill_arc: std::sync::Arc = { - let child_holder = std::sync::Arc::new(Mutex::new(Some(child))); - let holder_for_wait = child_holder.clone(); - tokio::spawn(async move { - let code = { - let mut guard = holder_for_wait.lock().await; - match guard.as_mut() { - Some(c) => c.wait().await.map(|s| s.code().unwrap_or(1)).unwrap_or(1), - None => 1, - } - }; - let _ = tx_exit.send(code); - }); - let holder_for_kill = child_holder.clone(); - std::sync::Arc::new(move || { - if let Ok(mut guard) = holder_for_kill.try_lock() { - if let Some(c) = guard.as_mut() { - let _ = c.start_kill(); - } - } - }) - }; + let (rx_exit, cancel) = nest::lifecycle::supervise(child); trace!(target: "claude-cli", "spawned pid={:?}", pid); @@ -229,7 +205,7 @@ impl WorkerBee for ClaudeCliWorker { events: std::sync::Arc::new(Mutex::new(rx_evt)), exited: rx_exit, ephemeral: false, - kill: kill_arc, + cancel, }) } } diff --git a/hives/claude-repl/Cargo.toml b/hives/claude-repl/Cargo.toml index e9588658..1cee948d 100644 --- a/hives/claude-repl/Cargo.toml +++ b/hives/claude-repl/Cargo.toml @@ -14,6 +14,7 @@ hum-paths = { path = "../../hum-paths" } nest = { path = "../../nest" } nest-common = { path = "../common" } tokio = { workspace = true, features = ["full"] } +tokio-util = "0.7" serde_json = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/hives/claude-repl/src/lib.rs b/hives/claude-repl/src/lib.rs index 27f936f9..35ccb4ad 100644 --- a/hives/claude-repl/src/lib.rs +++ b/hives/claude-repl/src/lib.rs @@ -192,17 +192,13 @@ impl WorkerBee for ClaudeReplWorker { let _ = tx_exit.send(code); }); - // master must outlive the writer; stash it behind Arc> - // through a kill closure. We move the master into the closure. - let master = std::sync::Arc::new(std::sync::Mutex::new(Some(pair.master))); - let master_for_kill = master.clone(); - let kill_arc: std::sync::Arc = - std::sync::Arc::new(move || { - if let Ok(mut g) = master_for_kill.lock() { - // Dropping the master closes the PTY → child gets SIGHUP. - *g = None; - } - }); + let cancel = tokio_util::sync::CancellationToken::new(); + let cancel_watch = cancel.clone(); + let mut master_holder = Some(pair.master); + tokio::spawn(async move { + cancel_watch.cancelled().await; + master_holder.take(); + }); trace!(target: "nest", "pty.spawned pid={:?}", pid); @@ -212,7 +208,7 @@ impl WorkerBee for ClaudeReplWorker { events: std::sync::Arc::new(Mutex::new(rx_evt)), exited: rx_exit, ephemeral: true, - kill: kill_arc, + cancel, }) } } diff --git a/hives/common/Cargo.toml b/hives/common/Cargo.toml index d502a8ff..0f04ae4a 100644 --- a/hives/common/Cargo.toml +++ b/hives/common/Cargo.toml @@ -21,6 +21,9 @@ regex = "1" serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["net", "io-util", "sync", "macros", "rt", "rt-multi-thread", "time"] } +tokio-util = "0.7" +lru = "0.12" +metrics = "0.23" tracing = { workspace = true } anyhow = { workspace = true } futures = "0.3" diff --git a/hives/common/src/serve.rs b/hives/common/src/serve.rs index 7ca7d44b..e018f8fe 100644 --- a/hives/common/src/serve.rs +++ b/hives/common/src/serve.rs @@ -21,10 +21,13 @@ //! re-handshake. use std::collections::HashMap; +use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; +use lru::LruCache; + use anyhow::{Context, Result}; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -94,7 +97,8 @@ pub async fn serve_worker(worker: Arc, advert: HiveAd } } } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let jitter = rand::random::() * 0.75; + tokio::time::sleep(std::time::Duration::from_secs_f32(2.0 + jitter)).await; } } @@ -160,7 +164,8 @@ async fn dial_and_serve( // Per-sid cell handles + a kill-fn registry so chi:"cancel" can // reach the right child. - let cells: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let cells: Arc>> = + Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(MAX_CELLS).unwrap()))); let mut reader = BufReader::new(read_half).lines(); while let Some(line) = reader.next_line().await? { @@ -204,12 +209,12 @@ async fn dial_and_serve( } "cancel" => { if !sid.is_empty() { - let r = cells.lock().await; + let mut r = cells.lock().await; if let Some(bundle) = r.get(&sid) { if let Some(rid) = tone.get("rid").and_then(Value::as_str) { let _ = bundle.stdin.send(encode_cancel(rid)).await; } - (bundle.kill)(); + bundle.cancel.cancel(); } } } @@ -224,7 +229,7 @@ async fn dial_and_serve( .map(|cid| bridge.resolve(cid, tone.clone())) .unwrap_or(false); if !resolved_by_bridge && !sid.is_empty() { - let r = cells.lock().await; + let mut r = cells.lock().await; if let Some(bundle) = r.get(&sid) { if let (Some(call_id), Some(result)) = ( tone.get("callId").and_then(Value::as_str), @@ -248,12 +253,16 @@ async fn dial_and_serve( struct CellBundle { stdin: mpsc::Sender, - kill: Arc, + cancel: tokio_util::sync::CancellationToken, finish_sent: Arc, tool_use_blocks: Arc>>, last_touched: Arc, } +impl Drop for CellBundle { + fn drop(&mut self) { self.cancel.cancel(); } +} + /// Build a `ToolDef` from a wire tone entry. MCP standard field is /// `inputSchema`; tolerate legacy `parameters` (some hum-side /// shims still emit it). Drop entries with no name OR no usable @@ -316,27 +325,27 @@ async fn attempt_spawn( ) -> Option<(Cell, Value)> { let cell = worker.spawn(spec).await.ok()?; if cell.stdin.send(encode_prompt(content)).await.is_err() { - (cell.kill)(); + cell.cancel.cancel(); return None; } let first = { let mut ev = cell.events.lock().await; match tokio::time::timeout(std::time::Duration::from_secs(180), ev.recv()).await { Ok(Some(v)) => Some(v), - _ => None, // timeout, or the process died without emitting + _ => None, } }; match first { - Some(v) if is_preflight_error(&v) => { (cell.kill)(); None } + Some(v) if is_preflight_error(&v) => { cell.cancel.cancel(); None } Some(v) => Some((cell, v)), - None => { (cell.kill)(); None } + None => { cell.cancel.cancel(); None } } } async fn handle_prompt( worker: Arc, write_half: Arc>, - cells: Arc>>, + cells: Arc>>, hive: String, mcp_url: String, tone: Value, @@ -366,7 +375,7 @@ async fn handle_prompt( // turn. Reuse the warm cell for the sid; per-turn state lives in // finish_sent + tool_use_blocks and must reset before re-entry. { - let g = cells.lock().await; + let mut g = cells.lock().await; if let Some(bundle) = g.get(&sid) { bundle.finish_sent.store(false, Ordering::SeqCst); bundle.tool_use_blocks.lock().await.clear(); @@ -380,20 +389,15 @@ async fn handle_prompt( } } - // No warm cell — evict LRU if at cap, then spawn fresh. { let mut g = cells.lock().await; if g.len() >= MAX_CELLS { - let evict_sid = g.iter() - .min_by_key(|(_, b)| b.last_touched.load(Ordering::SeqCst)) - .map(|(k, _)| k.clone()); - if let Some(esid) = evict_sid { - if let Some(bundle) = g.remove(&esid) { - warn!(evicted_sid = %esid, "worker.cell.evict.lru"); - (bundle.kill)(); - } + if let Some((esid, _evicted)) = g.pop_lru() { + warn!(evicted_sid = %esid, "worker.cell.evict.lru"); + metrics::counter!("hum_cell_evictions_total", "reason" => "lru").increment(1); } } + metrics::gauge!("hum_cells_active").set(g.len() as f64); } let mut base = SpawnSpec::new(sid.clone(), model.clone(), cwd); @@ -430,7 +434,7 @@ async fn handle_prompt( }; let stdin = cell.stdin.clone(); let events = cell.events.clone(); - let kill = cell.kill.clone(); + let cancel = cell.cancel.clone(); let finish_sent = Arc::new(AtomicBool::new(false)); let tool_use_blocks = Arc::new(Mutex::new(std::collections::BTreeSet::new())); let last_touched = Arc::new(AtomicU64::new(now_ms())); @@ -464,7 +468,6 @@ async fn handle_prompt( // Idle reaper — kills the cell if last_touched stays below the // IDLE_TIMEOUT_MS threshold. let cells_for_idle = cells.clone(); - let kill_for_idle = kill.clone(); let sid_for_idle = sid.clone(); let last_for_idle = last_touched.clone(); let idle_task = tokio::spawn(async move { @@ -474,18 +477,17 @@ async fn handle_prompt( let age = now_ms().saturating_sub(last); if age >= IDLE_TIMEOUT_MS { let mut g = cells_for_idle.lock().await; - if g.remove(&sid_for_idle).is_some() { + if g.pop(&sid_for_idle).is_some() { warn!(sid = %sid_for_idle, age_ms = age, "worker.cell.idle.kill"); - (kill_for_idle)(); } return; } } }); - cells.lock().await.insert(sid.clone(), CellBundle { + cells.lock().await.put(sid.clone(), CellBundle { stdin: stdin.clone(), - kill: kill.clone(), + cancel: cancel.clone(), finish_sent: finish_sent.clone(), tool_use_blocks: tool_use_blocks.clone(), last_touched: last_touched.clone(), @@ -514,7 +516,7 @@ async fn handle_prompt( let line = format!("{}\n", finish); let _ = write_for_cleanup.lock().await.write_all(line.as_bytes()).await; } - cells_for_cleanup.lock().await.remove(&sid_for_cleanup); + cells_for_cleanup.lock().await.pop(&sid_for_cleanup); trace!(sid = %sid_for_cleanup, exit_code, "worker.cell.exit"); }); diff --git a/humd/Cargo.toml b/humd/Cargo.toml index 0a22337e..3dcdbc69 100644 --- a/humd/Cargo.toml +++ b/humd/Cargo.toml @@ -38,6 +38,8 @@ hex = { workspace = true } parking_lot = { workspace = true } rand = { workspace = true } ed25519-dalek = { version = "2", features = ["rand_core"] } +metrics = "0.23" +metrics-exporter-prometheus = { version = "0.15", default-features = false, features = ["http-listener"] } [dev-dependencies] tokio = { workspace = true } diff --git a/humd/src/lib.rs b/humd/src/lib.rs index 3abf8f1e..eb3634fc 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -139,6 +139,16 @@ where "humd.sockets" ); + if let Some(addr) = metrics_listen_addr() { + match metrics_exporter_prometheus::PrometheusBuilder::new() + .with_http_listener(addr) + .install() + { + Ok(()) => info!(%addr, "humd.metrics.listening"), + Err(e) => warn!(%addr, err = %e, "humd.metrics.install_failed"), + } + } + let _hums = hums::Hums::load(); let penny = penny::Penny::load(&cfg.penny_path); penny.clone().spawn_persister(cfg.penny_path.clone(), cfg.penny_persist_interval); @@ -359,6 +369,12 @@ async fn autoupdate_check_once() -> Result { Ok(true) } +fn metrics_listen_addr() -> Option { + std::env::var("HUM_METRICS_ADDR").ok() + .and_then(|s| s.parse().ok()) + .or_else(|| "127.0.0.1:9909".parse().ok()) +} + fn parse_tag_name(body: &str) -> Option { let needle = "\"tag_name\":"; let start = body.find(needle)? + needle.len(); diff --git a/nest/Cargo.toml b/nest/Cargo.toml index 3b700ff9..05cbd460 100644 --- a/nest/Cargo.toml +++ b/nest/Cargo.toml @@ -7,6 +7,11 @@ description = "LLM subprocess pool. Defines WorkerBee + ForagerBee traits, Cell [dependencies] tokio = { workspace = true } +tokio-util = "0.7" +command-group = { version = "5", features = ["with-tokio"] } +lru = "0.12" +sysinfo = { version = "0.32", default-features = false, features = ["system"] } +metrics = "0.23" serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } @@ -18,3 +23,6 @@ portable-pty = "0.9" [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2" + +[dev-dependencies] +libc = "0.2" diff --git a/nest/src/lib.rs b/nest/src/lib.rs index cf3cd2d2..f314fe70 100644 --- a/nest/src/lib.rs +++ b/nest/src/lib.rs @@ -16,7 +16,9 @@ use anyhow::Result; use async_trait::async_trait; use serde_json::Value; use tokio::sync::{mpsc, Mutex}; +use tokio_util::sync::CancellationToken; +pub mod lifecycle; // own + supervise tokio::process::Child correctly pub mod mock; pub mod pool; @@ -115,8 +117,8 @@ pub struct Cell { pub exited: tokio::sync::oneshot::Receiver, /// True for PTY/REPL-style cells the pool evicts on each `result`. pub ephemeral: bool, - /// Kill the child. Best-effort; safe to call multiple times. - pub kill: Arc, + /// `.cancel()` → SIGKILL + reap. Idempotent. + pub cancel: CancellationToken, } /// Statefulness propensity of a bee — the same axis hives carry diff --git a/nest/src/lifecycle.rs b/nest/src/lifecycle.rs new file mode 100644 index 00000000..c1d3264c --- /dev/null +++ b/nest/src/lifecycle.rs @@ -0,0 +1,108 @@ +//! Own a child process group; expose a cancel handle that tree-kills. + +use std::time::Duration; + +use command_group::AsyncGroupChild; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +const REAP_TIMEOUT: Duration = Duration::from_secs(5); +const SIGKILL_EXIT: i32 = 137; + +pub fn supervise(mut child: AsyncGroupChild) -> (oneshot::Receiver, CancellationToken) { + let cancel = CancellationToken::new(); + let cancel_for_task = cancel.clone(); + let (tx_exit, rx_exit) = oneshot::channel(); + let pid = child.inner().id(); + + tokio::spawn(async move { + let code = tokio::select! { + biased; + _ = cancel_for_task.cancelled() => kill_and_reap(&mut child, pid).await, + result = child.wait() => match result { + Ok(status) => status.code().unwrap_or(1), + Err(e) => { warn!(target: "nest::lifecycle", pid = ?pid, err = %e, "cell.wait_failed"); 1 } + } + }; + let _ = tx_exit.send(code); + }); + + (rx_exit, cancel) +} + +async fn kill_and_reap(child: &mut AsyncGroupChild, pid: Option) -> i32 { + metrics::counter!("hum_cell_kills_total").increment(1); + if let Err(e) = child.kill().await { + warn!(target: "nest::lifecycle", pid = ?pid, err = %e, "cell.kill.signal_failed"); + } + match tokio::time::timeout(REAP_TIMEOUT, child.wait()).await { + Ok(Ok(status)) => status.code().unwrap_or(SIGKILL_EXIT), + Ok(Err(e)) => { warn!(target: "nest::lifecycle", pid = ?pid, err = %e, "cell.kill.reap_failed"); SIGKILL_EXIT } + Err(_) => { warn!(target: "nest::lifecycle", pid = ?pid, "cell.kill.reap_timeout"); SIGKILL_EXIT } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use command_group::AsyncCommandGroup; + use std::process::Stdio; + use std::time::Instant; + use tokio::process::Command; + + #[tokio::test] + async fn cancel_kills_the_child() { + let child = Command::new("sleep").arg("60") + .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()) + .group_spawn().expect("spawn sleep"); + let (rx_exit, cancel) = supervise(child); + let start = Instant::now(); + cancel.cancel(); + let code = tokio::time::timeout(Duration::from_secs(REAP_TIMEOUT.as_secs() + 1), rx_exit) + .await.expect("supervisor stuck").expect("exit channel dropped"); + assert!(start.elapsed() < REAP_TIMEOUT); + assert_eq!(code, SIGKILL_EXIT); + } + + #[tokio::test] + async fn natural_exit_propagates_code() { + let child = Command::new("sh").arg("-c").arg("exit 42") + .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()) + .group_spawn().expect("spawn sh"); + let (rx_exit, _cancel) = supervise(child); + let code = tokio::time::timeout(Duration::from_secs(5), rx_exit) + .await.expect("stuck").expect("dropped"); + assert_eq!(code, 42); + } + + #[tokio::test] + async fn tree_kill_takes_descendants() { + let marker = std::env::temp_dir().join(format!("hum-tree-kill-{}", std::process::id())); + let _ = std::fs::remove_file(&marker); + let marker_path = marker.to_string_lossy().to_string(); + let script = format!( + "sh -c 'sleep 60 & echo $! > {marker_path}; wait'" + ); + let child = Command::new("sh").arg("-c").arg(&script) + .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()) + .group_spawn().expect("spawn sh"); + let (_rx, cancel) = supervise(child); + + // wait for the grandchild pid to appear + for _ in 0..30 { + if marker.exists() { break; } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let grandchild_pid: u32 = std::fs::read_to_string(&marker) + .expect("grandchild marker").trim().parse().expect("pid parse"); + + cancel.cancel(); + tokio::time::sleep(Duration::from_millis(300)).await; + + // Sending signal 0 returns ESRCH if the process is gone. + let alive = unsafe { libc::kill(grandchild_pid as i32, 0) } == 0; + let _ = std::fs::remove_file(&marker); + assert!(!alive, "grandchild {grandchild_pid} survived tree-kill"); + } +} diff --git a/nest/src/metrics.rs b/nest/src/metrics.rs index 9d18f0ad..77a2f426 100644 --- a/nest/src/metrics.rs +++ b/nest/src/metrics.rs @@ -1,77 +1,48 @@ //! Per-cell observability — RSS, CPU, fd count, wall-clock age. -//! -//! A `CellMetrics` is a point-in-time snapshot of one cell's OS-visible -//! resource use. `sample(pid, spawned_at_ms)` reads it. On Linux we lift -//! the numbers out of `/proc//{statm,stat,fd}`; on other platforms -//! we fill in just the fields we can compute without the kernel (age, -//! sampled_at) and leave the rest as `None` — the caller logs that the -//! platform doesn't support full sampling. -//! -//! Sampling is best-effort. A process that exits between the `read_dir` -//! and the `read_to_string` shows up as a partial snapshot rather than -//! a panic. That's deliberate: cells come and go, and a metrics layer -//! that crashes its sampler defeats the purpose. -//! -//! The optional `spawn_sampler` helper runs a sampler in a tokio task at -//! a fixed interval and pushes snapshots into an unbounded channel. The -//! daemon owns the receiver and decides whether to aggregate / log / -//! forward upstream. use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; +use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System}; use tokio::sync::mpsc; -/// Point-in-time snapshot of one cell's OS-visible resource use. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CellMetrics { pub pid: Option, - /// Resident set size in bytes. None if unavailable on this platform. pub rss_bytes: Option, - /// Cumulative CPU time in milliseconds (user+system). pub cpu_ms: Option, - /// Open file descriptor count. None if unavailable. pub fd_count: Option, - /// Wall-clock age of the cell in milliseconds. pub age_ms: u64, - /// Wall-clock timestamp this snapshot was taken (ms since UNIX epoch). pub sampled_at_ms: i64, } -/// Read a one-shot snapshot for the given pid. On Linux this reads from -/// /proc//{statm, stat, fd}. On other platforms it returns a -/// snapshot with most fields set to None; this is deliberate — the -/// caller logs that the platform doesn't support full sampling. pub fn sample(pid: u32, spawned_at_ms: i64) -> CellMetrics { let sampled_at_ms = now_ms(); - let age_ms = sampled_at_ms - .saturating_sub(spawned_at_ms) - .max(0) as u64; - + let age_ms = sampled_at_ms.saturating_sub(spawned_at_ms).max(0) as u64; let mut m = CellMetrics { pid: Some(pid), - rss_bytes: None, - cpu_ms: None, - fd_count: None, age_ms, sampled_at_ms, + ..Default::default() }; + let mut sys = System::new_with_specifics( + RefreshKind::new().with_processes(ProcessRefreshKind::everything()), + ); + sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), true); + if let Some(p) = sys.process(Pid::from_u32(pid)) { + m.rss_bytes = Some(p.memory()); + m.cpu_ms = Some(p.cpu_usage() as u64); + } + #[cfg(target_os = "linux")] { - m.rss_bytes = read_rss_linux(pid); - m.cpu_ms = read_cpu_ms_linux(pid); - m.fd_count = read_fd_count_linux(pid); + m.fd_count = fd_count_linux(pid); } m } -/// Spawn a background tokio task that samples the given pid every -/// `interval` and pushes snapshots into the returned receiver. The task -/// exits when the receiver is dropped (channel send fails). -/// -/// The returned `JoinHandle` lets the caller abort the sampler early. pub fn spawn_sampler( pid: u32, spawned_at_ms: i64, @@ -80,145 +51,53 @@ pub fn spawn_sampler( let (tx, rx) = mpsc::unbounded_channel(); let handle = tokio::spawn(async move { let mut tick = tokio::time::interval(interval); - // First tick fires immediately; we want one sample up front. loop { tick.tick().await; let snap = sample(pid, spawned_at_ms); - if tx.send(snap).is_err() { - // Receiver dropped — no one is listening, so stop sampling. - break; - } + if tx.send(snap).is_err() { break; } } }); (rx, handle) } fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -// ---- Linux /proc parsing ---------------------------------------------------- - -#[cfg(target_os = "linux")] -fn page_size() -> u64 { - // SAFETY: sysconf is thread-safe and always defined for _SC_PAGESIZE. - let p = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; - if p > 0 { - p as u64 - } else { - // Sane fallback for x86_64/aarch64. Almost never hit. - 4096 - } -} - -#[cfg(target_os = "linux")] -fn clk_tck() -> u64 { - let t = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; - if t > 0 { - t as u64 - } else { - 100 - } + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) } -/// /proc//statm: "size resident shared text lib data dt" in pages. -/// We want word index 1 (resident) * page_size. #[cfg(target_os = "linux")] -fn read_rss_linux(pid: u32) -> Option { - let raw = std::fs::read_to_string(format!("/proc/{}/statm", pid)).ok()?; - let mut it = raw.split_ascii_whitespace(); - let _size = it.next()?; - let resident: u64 = it.next()?.parse().ok()?; - Some(resident.saturating_mul(page_size())) +fn fd_count_linux(pid: u32) -> Option { + std::fs::read_dir(format!("/proc/{}/fd", pid)).ok() + .map(|d| d.flatten().count() as u32) } -/// /proc//stat: utime is field 14, stime is field 15 (1-indexed). -/// Field 2 is `(comm)` which may contain spaces and parens — locate the -/// LAST `)` and split from there so the fields after `comm` are safe to -/// whitespace-split. -#[cfg(target_os = "linux")] -fn read_cpu_ms_linux(pid: u32) -> Option { - let raw = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?; - let close = raw.rfind(')')?; - // After the ')' there's a space then field 3 (state). So field N (N>=3) - // in the original 1-indexed layout is at index (N - 3) of the tail - // split by whitespace. - let tail = raw[close + 1..].trim(); - let parts: Vec<&str> = tail.split_ascii_whitespace().collect(); - // utime = field 14 → tail index 14 - 3 = 11 - // stime = field 15 → tail index 15 - 3 = 12 - let utime: u64 = parts.get(11)?.parse().ok()?; - let stime: u64 = parts.get(12)?.parse().ok()?; - let ticks = utime.saturating_add(stime); - let hz = clk_tck(); - if hz == 0 { - return None; - } - Some(ticks.saturating_mul(1000) / hz) -} - -/// Count entries in /proc//fd. Each entry is one open fd. -#[cfg(target_os = "linux")] -fn read_fd_count_linux(pid: u32) -> Option { - let dir = std::fs::read_dir(format!("/proc/{}/fd", pid)).ok()?; - let mut n: u32 = 0; - for e in dir { - // Ignore individual errors — fds churn while we iterate. - if e.is_ok() { - n = n.saturating_add(1); - } - } - Some(n) -} - -// ---- Tests ------------------------------------------------------------------ - #[cfg(test)] mod tests { use super::*; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; + use std::time::Duration; fn now() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis() as i64 + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) } - #[cfg(target_os = "linux")] #[test] - fn sample_self_has_rss_on_linux() { + fn sample_self_has_rss() { let snap = sample(std::process::id(), now()); - assert!(snap.rss_bytes.is_some(), "rss missing for self pid"); - assert!(snap.rss_bytes.unwrap() > 0, "rss should be > 0"); - // While we're here: cpu_ms and fd_count should also resolve for self. - assert!(snap.cpu_ms.is_some(), "cpu_ms missing for self pid"); - assert!(snap.fd_count.is_some(), "fd_count missing for self pid"); + assert!(snap.rss_bytes.unwrap_or(0) > 0, "rss should be non-zero for self"); } #[test] fn sample_unknown_pid_returns_defaults() { let snap = sample(999_999_999, now()); - assert!(snap.rss_bytes.is_none(), "no rss for nonexistent pid"); - assert!(snap.cpu_ms.is_none(), "no cpu_ms for nonexistent pid"); - assert!(snap.fd_count.is_none(), "no fd_count for nonexistent pid"); - assert_eq!(snap.pid, Some(999_999_999)); + assert!(snap.rss_bytes.is_none()); + assert!(snap.cpu_ms.is_none()); } - #[test] - fn age_ms_increases_over_time() { - let spawned = now(); - let a = sample(std::process::id(), spawned); - std::thread::sleep(Duration::from_millis(10)); - let b = sample(std::process::id(), spawned); - assert!( - b.age_ms > a.age_ms, - "age_ms should grow: a={} b={}", - a.age_ms, - b.age_ms - ); + #[tokio::test] + async fn sampler_emits_snapshots() { + let (mut rx, handle) = spawn_sampler(std::process::id(), now(), Duration::from_millis(50)); + let first = tokio::time::timeout(Duration::from_secs(1), rx.recv()).await + .expect("no sample within 1s").expect("channel closed"); + assert_eq!(first.pid, Some(std::process::id())); + handle.abort(); } } diff --git a/nest/src/mock.rs b/nest/src/mock.rs index 06dcbcb9..afaf4036 100644 --- a/nest/src/mock.rs +++ b/nest/src/mock.rs @@ -116,15 +116,13 @@ impl WorkerBee for MockWorkerBee { let _ = tx_exit.send(0); }); - let kill: Arc = Arc::new(|| {}); - Ok(Cell { pid: None, stdin: tx_in, events: Arc::new(Mutex::new(rx_evt)), exited: rx_exit, ephemeral: false, - kill, + cancel: tokio_util::sync::CancellationToken::new(), }) } } diff --git a/nest/src/pool.rs b/nest/src/pool.rs index 63ca4efd..edac6671 100644 --- a/nest/src/pool.rs +++ b/nest/src/pool.rs @@ -243,7 +243,7 @@ impl Nest { if let Some(slot) = slots.get(&pk) { if slot.listeners.lock().await.is_empty() { trace!(target: "nest", pool_key = %pk, "nest.idle"); - (slot.cell.kill)(); + slot.cell.cancel.cancel(); slots.remove(&pk); } } @@ -256,7 +256,7 @@ impl Nest { let mut slots = self.slots.write().await; if let Some(slot) = slots.remove(pool_key) { trace!(target: "nest", %pool_key, "nest.felled"); - (slot.cell.kill)(); + slot.cell.cancel.cancel(); } } @@ -264,7 +264,7 @@ impl Nest { pub async fn silence(&self) { let mut slots = self.slots.write().await; for (_, slot) in slots.drain() { - (slot.cell.kill)(); + slot.cell.cancel.cancel(); } } @@ -285,7 +285,7 @@ impl Nest { if let Some(k) = evict_key { if let Some(slot) = slots.remove(&k) { trace!(target: "nest", pool_key = %k, "nest.evicted reason=maxActiveCells"); - (slot.cell.kill)(); + slot.cell.cancel.cancel(); } } } diff --git a/thrumd/Cargo.toml b/thrumd/Cargo.toml index 09f088dc..014751a9 100644 --- a/thrumd/Cargo.toml +++ b/thrumd/Cargo.toml @@ -16,3 +16,4 @@ async-trait = { workspace = true } anyhow = { workspace = true } parking_lot = { workspace = true } uuid = { version = "1", features = ["v4"] } +governor = "0.6" diff --git a/thrumd/src/lib.rs b/thrumd/src/lib.rs index c52863d9..fecb34eb 100644 --- a/thrumd/src/lib.rs +++ b/thrumd/src/lib.rs @@ -19,6 +19,7 @@ use async_trait::async_trait; use parking_lot::RwLock; use serde_json::{json, Value}; use thrum_core::THRUM_VERSION; +use governor::{Quota, RateLimiter}; use tokio::net::UnixListener; use tracing::{info, trace, warn}; @@ -217,7 +218,10 @@ pub async fn serve(thrum: Thrum, path: impl AsRef) -> Result<()> { .with_context(|| format!("bind unix socket {:?}", path))?; info!(path = %path.display(), version = %THRUM_VERSION, "thrum.listening"); + let limiter = RateLimiter::direct(Quota::per_second(std::num::NonZeroU32::new(100).unwrap())); + loop { + limiter.until_ready().await; match listener.accept().await { Ok((sock, _)) => { let thrum = thrum.clone(); From 96c5c1f6fc1b89ccff205d5f7f67fb91249c7c13 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 16:09:04 +0000 Subject: [PATCH 03/18] hum-paths: xdg() self-initializes; init() becomes eager warmup DaemonConfig::from_env() (and a few other early callers) hit hum_paths functions before any init() ran. Tests panicked. Making xdg() call init() on first miss makes init() optional everywhere; explicit init() at bin entry stays as an eager warmup so child processes inherit the env. --- hum-paths/src/lib.rs | 9 +++++---- humd/src/lib.rs | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index 5697a519..b917a194 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -127,10 +127,11 @@ pub fn macos_log(unit: &str) -> (PathBuf, PathBuf) { // ── helpers ─────────────────────────────────────────────────────────────────── fn xdg(var: &str) -> PathBuf { - PathBuf::from( - std::env::var_os(var) - .unwrap_or_else(|| panic!("{var} not set — call hum_paths::init() at process startup")), - ) + if let Some(v) = std::env::var_os(var) { + return PathBuf::from(v); + } + init(); + PathBuf::from(std::env::var_os(var).expect("init() set the var")) } fn home() -> PathBuf { diff --git a/humd/src/lib.rs b/humd/src/lib.rs index eb3634fc..8049eadd 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -132,6 +132,8 @@ pub async fn run(mut cfg: DaemonConfig, shutdown: F) -> Result<()> where F: std::future::Future + Send, { + hum_paths::init(); + info!( thrum = %cfg.thrum_path.display(), http = %cfg.http_path.display(), From af154b647fbe54035d54f056af09e01dcfbe4193 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 17:02:03 +0000 Subject: [PATCH 04/18] nest cleanup: kill orphans, wire missing layers, add absent tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dead modules removed (1500 LOC): - nest::pool (Nest, never used) - nest::mock (only used by pool tests) - nest::health (tiered eviction policy, no callers) - nest::budget (token/tool-call caps, no callers) - nest::Listener + nest::ForagerBee traits (Listener impl in serve.rs was empty stubs; ForagerBee had zero impls) Wired: - nest::metrics::spawn_sampler now drives a hum_cell_rss_bytes / hum_cell_cpu_ms gauge labelled by pid from inside lifecycle::supervise. Per-cell observability stops being a half-built feature. - humd.metricsAddr config knob (hum.json) replaces the hardcoded 127.0.0.1:9909. Schema entry added. Naming standardized: hum_cells_active -> hum_cell_count. Dead code removed in hives/common/src/serve.rs (HashMap import, WireListener Listener impl), humd/src/lib.rs (HumdSink.cli_path), hum/src/main.rs (home() helper). Tests added: - serve::tests::drop_cancels_token — RAII fires on drop - serve::tests::lru_pop_drops_bundle_and_cancels — LRU eviction kills - serve::tests::map_clear_cancels_all — shutdown kills everything - humd::prometheus_endpoint — /metrics actually serves - thrumd::accept_rate_limit — governor paces accepts at quota EOF --- config/src/lib.rs | 6 + hives/common/src/serve.rs | 71 ++++- hum.schema.json | 5 + hum/src/main.rs | 4 - humd/Cargo.toml | 2 + humd/src/lib.rs | 12 +- humd/tests/prometheus_endpoint.rs | 43 +++ nest/src/budget.rs | 331 --------------------- nest/src/health.rs | 428 --------------------------- nest/src/lib.rs | 54 +--- nest/src/lifecycle.rs | 43 ++- nest/src/mock.rs | 277 ----------------- nest/src/pool.rs | 477 ------------------------------ thrumd/Cargo.toml | 3 + thrumd/tests/accept_rate_limit.rs | 17 ++ 15 files changed, 178 insertions(+), 1595 deletions(-) create mode 100644 humd/tests/prometheus_endpoint.rs delete mode 100644 nest/src/budget.rs delete mode 100644 nest/src/health.rs delete mode 100644 nest/src/mock.rs delete mode 100644 nest/src/pool.rs create mode 100644 thrumd/tests/accept_rate_limit.rs diff --git a/config/src/lib.rs b/config/src/lib.rs index 9dfbfa8b..2cc53037 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -18,6 +18,8 @@ pub struct HumdSection { pub permission_dusk_ms: u64, #[serde(default = "defaults::drift_retention_days", rename = "driftRetentionDays")] pub drift_retention_days: u32, + #[serde(default = "defaults::metrics_addr", rename = "metricsAddr")] + pub metrics_addr: String, } impl Default for HumdSection { @@ -25,6 +27,7 @@ impl Default for HumdSection { Self { permission_dusk_ms: defaults::permission_dusk_ms(), drift_retention_days: defaults::drift_retention_days(), + metrics_addr: defaults::metrics_addr(), } } } @@ -272,6 +275,9 @@ mod defaults { pub fn drift_retention_days() -> u32 { 30 } + pub fn metrics_addr() -> String { + "127.0.0.1:9909".into() + } pub fn max_active_cells() -> u32 { 4 } diff --git a/hives/common/src/serve.rs b/hives/common/src/serve.rs index e018f8fe..34fda56e 100644 --- a/hives/common/src/serve.rs +++ b/hives/common/src/serve.rs @@ -20,7 +20,6 @@ //! Reconnect is built in — humd restarts don't strand workers; they //! re-handshake. -use std::collections::HashMap; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -37,7 +36,7 @@ use tracing::{debug, info, trace, warn}; use ensemble::HidPrefix; use mcp::protocol::ToolDef; -use nest::{encode_cancel, encode_prompt, encode_tool_result, Cell, Listener, SpawnSpec, WorkerBee}; +use nest::{encode_cancel, encode_prompt, encode_tool_result, Cell, SpawnSpec, WorkerBee}; use tokio::sync::mpsc; use crate::identity::load_or_mint_bee_key; @@ -397,7 +396,7 @@ async fn handle_prompt( metrics::counter!("hum_cell_evictions_total", "reason" => "lru").increment(1); } } - metrics::gauge!("hum_cells_active").set(g.len() as f64); + metrics::gauge!("hum_cell_count").set(g.len() as f64); } let mut base = SpawnSpec::new(sid.clone(), model.clone(), cwd); @@ -693,11 +692,63 @@ impl WireListener { } } -#[async_trait::async_trait] -impl Listener for WireListener { - fn session_id(&self) -> &str { &self.sid } - async fn on_petal(&self, _kind: &str, _payload: Value) {} - async fn on_cell(&self, _nest_id: &str, _model: &str, _tools: Vec) {} - async fn on_wilt(&self, _finish_reason: &str, _usage: Option, _provider_meta: Value) {} - async fn on_thorn(&self, _wound: &str) {} +#[cfg(test)] +mod tests { + use super::*; + use tokio_util::sync::CancellationToken; + + fn bundle(cancel: CancellationToken) -> CellBundle { + let (tx_in, _rx_in) = mpsc::channel::(1); + CellBundle { + stdin: tx_in, + cancel, + finish_sent: Arc::new(AtomicBool::new(false)), + tool_use_blocks: Arc::new(Mutex::new(std::collections::BTreeSet::new())), + last_touched: Arc::new(AtomicU64::new(0)), + } + } + + #[tokio::test] + async fn drop_cancels_token() { + let cancel = CancellationToken::new(); + let watch = cancel.clone(); + let b = bundle(cancel); + assert!(!watch.is_cancelled()); + drop(b); + assert!(watch.is_cancelled()); + } + + #[tokio::test] + async fn lru_pop_drops_bundle_and_cancels() { + let mut cache: LruCache = LruCache::new(NonZeroUsize::new(2).unwrap()); + let c1 = CancellationToken::new(); + let watch1 = c1.clone(); + let c2 = CancellationToken::new(); + let watch2 = c2.clone(); + cache.put("a".into(), bundle(c1)); + cache.put("b".into(), bundle(c2)); + assert!(!watch1.is_cancelled()); + assert!(!watch2.is_cancelled()); + + let popped = cache.pop_lru().expect("non-empty"); + assert_eq!(popped.0, "a"); + drop(popped); + assert!(watch1.is_cancelled(), "evicted bundle should have cancelled on drop"); + assert!(!watch2.is_cancelled(), "remaining bundle untouched"); + } + + #[tokio::test] + async fn map_clear_cancels_all() { + let mut cache: LruCache = LruCache::new(NonZeroUsize::new(4).unwrap()); + let watchers: Vec<_> = (0..3).map(|i| { + let c = CancellationToken::new(); + let w = c.clone(); + cache.put(format!("s{i}"), bundle(c)); + w + }).collect(); + cache.clear(); + for w in watchers { + assert!(w.is_cancelled()); + } + } } diff --git a/hum.schema.json b/hum.schema.json index e479d8e9..0ba98fd1 100644 --- a/hum.schema.json +++ b/hum.schema.json @@ -22,6 +22,11 @@ "minimum": 0, "default": 30, "description": "Days of drift-ring samples kept on disk." + }, + "metricsAddr": { + "type": "string", + "default": "127.0.0.1:9909", + "description": "host:port the Prometheus exporter binds. Empty / unparseable disables the endpoint." } } }, diff --git a/hum/src/main.rs b/hum/src/main.rs index 327e7f98..404cccef 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -174,10 +174,6 @@ fn latest_release_tag() -> Option { // ─── helpers ───────────────────────────────────────────────────────────── -fn home() -> Result { - std::env::var_os("HOME").map(PathBuf::from).context("HOME unset") -} - fn humd_bin() -> Result { let candidates = [ std::env::var_os("HUM_BIN").map(PathBuf::from), diff --git a/humd/Cargo.toml b/humd/Cargo.toml index 3dcdbc69..5deefdc5 100644 --- a/humd/Cargo.toml +++ b/humd/Cargo.toml @@ -45,6 +45,8 @@ metrics-exporter-prometheus = { version = "0.15", default-features = false, feat tokio = { workspace = true } serde_json = { workspace = true } tempfile = "3" +metrics = "0.23" +metrics-exporter-prometheus = { version = "0.15", default-features = false, features = ["http-listener"] } [[example]] name = "smoke" diff --git a/humd/src/lib.rs b/humd/src/lib.rs index 8049eadd..bb6ccd61 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -141,7 +141,7 @@ where "humd.sockets" ); - if let Some(addr) = metrics_listen_addr() { + if let Ok(addr) = cfg.hum_cfg.humd.metrics_addr.parse::() { match metrics_exporter_prometheus::PrometheusBuilder::new() .with_http_listener(addr) .install() @@ -149,6 +149,8 @@ where Ok(()) => info!(%addr, "humd.metrics.listening"), Err(e) => warn!(%addr, err = %e, "humd.metrics.install_failed"), } + } else { + warn!(addr = %cfg.hum_cfg.humd.metrics_addr, "humd.metrics.addr_parse_failed"); } let _hums = hums::Hums::load(); @@ -242,7 +244,6 @@ where let sink: Arc = Arc::new(HumdSink { thrum: thrum.clone(), waneman: waneman.clone(), - cli_path: cfg.cli_path.clone(), ensemble: ensemble_for_sink.clone(), observers: observers.clone(), capacity_override: cfg.capacity_override, @@ -371,12 +372,6 @@ async fn autoupdate_check_once() -> Result { Ok(true) } -fn metrics_listen_addr() -> Option { - std::env::var("HUM_METRICS_ADDR").ok() - .and_then(|s| s.parse().ok()) - .or_else(|| "127.0.0.1:9909".parse().ok()) -} - fn parse_tag_name(body: &str) -> Option { let needle = "\"tag_name\":"; let start = body.find(needle)? + needle.len(); @@ -391,7 +386,6 @@ fn parse_tag_name(body: &str) -> Option { struct HumdSink { thrum: Thrum, waneman: Arc, - cli_path: String, /// When present, tones with a `to:` hex addressed to a *different* /// humd are routed through here instead of being dispatched locally. ensemble: Option>, diff --git a/humd/tests/prometheus_endpoint.rs b/humd/tests/prometheus_endpoint.rs new file mode 100644 index 00000000..2848a5b4 --- /dev/null +++ b/humd/tests/prometheus_endpoint.rs @@ -0,0 +1,43 @@ +use metrics_exporter_prometheus::PrometheusBuilder; +use std::net::{SocketAddr, TcpListener}; +use std::time::Duration; + +fn pick_addr() -> SocketAddr { + let s = TcpListener::bind("127.0.0.1:0").expect("bind"); + let a = s.local_addr().expect("addr"); + drop(s); + a +} + +#[tokio::test] +async fn prometheus_exporter_serves_metrics() { + let addr = pick_addr(); + PrometheusBuilder::new() + .with_http_listener(addr) + .install() + .expect("install exporter"); + + metrics::counter!("hum_test_counter").increment(7); + metrics::gauge!("hum_test_gauge").set(42.0); + tokio::time::sleep(Duration::from_millis(100)).await; + + let url = format!("http://{addr}/metrics"); + let resp = match tokio::time::timeout(Duration::from_secs(2), async { + let stream = tokio::net::TcpStream::connect(addr).await?; + let (mut r, mut w) = stream.into_split(); + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + w.write_all(format!("GET /metrics HTTP/1.0\r\nHost: {addr}\r\n\r\n").as_bytes()).await?; + let mut body = Vec::new(); + r.read_to_end(&mut body).await?; + Ok::<_, std::io::Error>(String::from_utf8_lossy(&body).into_owned()) + }).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => panic!("connect to {url}: {e}"), + Err(_) => panic!("timeout fetching {url}"), + }; + + assert!(resp.contains("hum_test_counter"), "missing counter in:\n{resp}"); + assert!(resp.contains("hum_test_gauge"), "missing gauge in:\n{resp}"); + assert!(resp.contains(" 7"), "counter value missing in:\n{resp}"); + assert!(resp.contains(" 42"), "gauge value missing in:\n{resp}"); +} diff --git a/nest/src/budget.rs b/nest/src/budget.rs deleted file mode 100644 index 83d734f4..00000000 --- a/nest/src/budget.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! Per-cell soft caps — tokens per turn / day, tool-call rate. -//! -//! The drone tracks `tokens_burned` per-sigil; this module wraps that -//! signal into a refuse-prompt gate that emits `chi:"error"` with -//! `code:"budget"` when limits would be exceeded. -//! -//! Pure data + arithmetic — no IO, no clocks beyond `SystemTime::now()`. - -use std::collections::HashMap; -use std::time::{SystemTime, UNIX_EPOCH}; - -use parking_lot::Mutex; -use serde::{Deserialize, Serialize}; - -const DAY_MS: i64 = 86_400_000; -const MINUTE_MS: i64 = 60_000; - -/// Configurable soft limits on a cell's consumption. `None` means -/// "no cap for this dimension." -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Budget { - /// Tokens (input + output) allowed in a single turn. - pub tokens_per_turn: Option, - /// Tokens allowed across a rolling 24h window. - pub tokens_per_day: Option, - /// Tool calls allowed per rolling 60s window. - pub tool_calls_per_minute: Option, -} - -impl Budget { - pub fn unlimited() -> Self { - Self::default() - } - - pub fn modest() -> Self { - Self { - tokens_per_turn: Some(8_000), - tokens_per_day: Some(1_000_000), - tool_calls_per_minute: Some(60), - } - } -} - -/// Reason a budget check refused a new prompt or tool-call. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "kebab-case")] -pub enum BudgetDenial { - TokensPerTurn { requested: u64, cap: u64 }, - TokensPerDay { used: u64, cap: u64 }, - ToolCallsPerMinute { used: u32, cap: u32 }, -} - -impl BudgetDenial { - /// Short, human-readable label — what goes into `chi:"error".message`. - pub fn message(&self) -> String { - match self { - BudgetDenial::TokensPerTurn { requested, cap } => format!( - "budget: tokens_per_turn exceeded (requested {requested}, cap {cap})" - ), - BudgetDenial::TokensPerDay { used, cap } => { - format!("budget: tokens_per_day exceeded (used {used}, cap {cap})") - } - BudgetDenial::ToolCallsPerMinute { used, cap } => format!( - "budget: tool_calls_per_minute exceeded (used {used}, cap {cap})" - ), - } - } -} - -/// Per-key running counters with windowed expiry. Cheap to clone — state -/// lives behind an `Arc>`. -#[derive(Debug, Clone, Default)] -pub struct BudgetTracker { - inner: std::sync::Arc>, -} - -#[derive(Debug, Default)] -struct Inner { - keys: HashMap, -} - -#[derive(Debug, Default)] -struct KeyState { - /// Token grants this turn (zeroed by `note_turn_end`). - tokens_this_turn: u64, - /// Rolling 24h: (ts_ms, tokens) entries; pruned on each touch. - tokens_day: Vec<(i64, u64)>, - /// Rolling 60s: ts_ms of each tool-call; pruned on each touch. - tool_calls_minute: Vec, -} - -impl KeyState { - fn prune_day(&mut self, now_ms: i64) { - let cutoff = now_ms - DAY_MS; - self.tokens_day.retain(|(ts, _)| *ts > cutoff); - } - - fn prune_minute(&mut self, now_ms: i64) { - let cutoff = now_ms - MINUTE_MS; - self.tool_calls_minute.retain(|ts| *ts > cutoff); - } - - fn day_total(&self) -> u64 { - self.tokens_day.iter().map(|(_, t)| *t).sum() - } -} - -fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -impl BudgetTracker { - pub fn new() -> Self { - Self::default() - } - - /// Check whether `key` may admit a new turn of approximately `est_tokens`. - /// `Ok(())` = admit; `Err(BudgetDenial)` = reason it was refused. The - /// check does NOT mutate state — callers that proceed must record the - /// turn via `record_tokens` so it counts against the rolling windows. - pub fn check_admit_turn( - &self, - key: &str, - est_tokens: u64, - budget: &Budget, - ) -> Result<(), BudgetDenial> { - let now = now_ms(); - let mut inner = self.inner.lock(); - let state = inner.keys.entry(key.to_string()).or_default(); - state.prune_day(now); - - if let Some(cap) = budget.tokens_per_turn { - if state.tokens_this_turn.saturating_add(est_tokens) > cap { - return Err(BudgetDenial::TokensPerTurn { - requested: est_tokens, - cap, - }); - } - } - - if let Some(cap) = budget.tokens_per_day { - let used = state.day_total(); - if used.saturating_add(est_tokens) > cap { - return Err(BudgetDenial::TokensPerDay { used, cap }); - } - } - - Ok(()) - } - - /// Check whether `key` may admit a new tool-call right now. - pub fn check_admit_tool_call( - &self, - key: &str, - budget: &Budget, - ) -> Result<(), BudgetDenial> { - let now = now_ms(); - let mut inner = self.inner.lock(); - let state = inner.keys.entry(key.to_string()).or_default(); - state.prune_minute(now); - - if let Some(cap) = budget.tool_calls_per_minute { - let used = state.tool_calls_minute.len() as u32; - if used.saturating_add(1) > cap { - return Err(BudgetDenial::ToolCallsPerMinute { used, cap }); - } - } - - Ok(()) - } - - /// Record `tokens` consumed against `key` — call from the drone's - /// TextDelta observer (or in batch on chi:"finish"). - pub fn record_tokens(&self, key: &str, tokens: u64) { - let now = now_ms(); - let mut inner = self.inner.lock(); - let state = inner.keys.entry(key.to_string()).or_default(); - state.prune_day(now); - state.tokens_this_turn = state.tokens_this_turn.saturating_add(tokens); - state.tokens_day.push((now, tokens)); - } - - /// Record one tool-call against `key`'s 60s window. - pub fn record_tool_call(&self, key: &str) { - let now = now_ms(); - let mut inner = self.inner.lock(); - let state = inner.keys.entry(key.to_string()).or_default(); - state.prune_minute(now); - state.tool_calls_minute.push(now); - } - - /// Reset this turn's tokens counter (call on chi:"finish"). - pub fn note_turn_end(&self, key: &str) { - let mut inner = self.inner.lock(); - if let Some(state) = inner.keys.get_mut(key) { - state.tokens_this_turn = 0; - } - } - - /// Drop the entire state for `key`. Call on chi:"cleanup". - pub fn forget(&self, key: &str) { - let mut inner = self.inner.lock(); - inner.keys.remove(key); - } - - /// Test-only: record a tool call with an explicit timestamp so tests - /// can plant entries in the past without sleeping. - #[cfg(test)] - pub(crate) fn record_tool_call_at(&self, key: &str, ts_ms: i64) { - let mut inner = self.inner.lock(); - let state = inner.keys.entry(key.to_string()).or_default(); - state.tool_calls_minute.push(ts_ms); - } -} - -/// Build the body of a `chi:"error"` tone signaling a budget denial. -/// The shape: `{ "code": "budget", "message": "...", "denial": }`. -pub fn deny_error_body(denial: &BudgetDenial) -> serde_json::Value { - serde_json::json!({ - "code": "budget", - "message": denial.message(), - "denial": denial, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_budget_is_unlimited() { - let tracker = BudgetTracker::new(); - let budget = Budget::default(); - assert!(tracker.check_admit_turn("k", 1_000_000, &budget).is_ok()); - } - - #[test] - fn tokens_per_turn_cap_refuses() { - let tracker = BudgetTracker::new(); - let budget = Budget { - tokens_per_turn: Some(100), - ..Default::default() - }; - let denial = tracker - .check_admit_turn("k", 200, &budget) - .expect_err("should refuse"); - assert_eq!( - denial, - BudgetDenial::TokensPerTurn { - requested: 200, - cap: 100, - } - ); - } - - #[test] - fn tokens_per_day_cap_refuses_after_accumulating() { - let tracker = BudgetTracker::new(); - let budget = Budget { - tokens_per_day: Some(1000), - ..Default::default() - }; - tracker.record_tokens("k", 800); - let denial = tracker - .check_admit_turn("k", 300, &budget) - .expect_err("should refuse"); - assert_eq!( - denial, - BudgetDenial::TokensPerDay { - used: 800, - cap: 1000, - } - ); - } - - #[test] - fn tool_calls_minute_cap_refuses() { - let tracker = BudgetTracker::new(); - let budget = Budget { - tool_calls_per_minute: Some(2), - ..Default::default() - }; - tracker.record_tool_call("k"); - tracker.record_tool_call("k"); - let denial = tracker - .check_admit_tool_call("k", &budget) - .expect_err("should refuse"); - assert!(matches!( - denial, - BudgetDenial::ToolCallsPerMinute { used: 2, cap: 2 } - )); - } - - #[test] - fn pruning_recovers_capacity() { - let tracker = BudgetTracker::new(); - let budget = Budget { - tool_calls_per_minute: Some(1), - ..Default::default() - }; - // Plant a tool-call 70s in the past — outside the rolling window. - let stale_ts = now_ms() - 70_000; - tracker.record_tool_call_at("k", stale_ts); - // The check should prune the stale entry and admit. - assert!(tracker.check_admit_tool_call("k", &budget).is_ok()); - } - - #[test] - fn denial_serializes_as_expected_chi_error_body() { - let denial = BudgetDenial::TokensPerTurn { - requested: 9000, - cap: 8000, - }; - let body = deny_error_body(&denial); - assert_eq!(body["code"], "budget"); - assert!( - body["message"] - .as_str() - .unwrap() - .contains("tokens_per_turn"), - "message should reference tokens_per_turn, got: {}", - body["message"] - ); - assert_eq!(body["denial"]["kind"], "tokens-per-turn"); - assert_eq!(body["denial"]["requested"], 9000); - assert_eq!(body["denial"]["cap"], 8000); - } -} diff --git a/nest/src/health.rs b/nest/src/health.rs deleted file mode 100644 index 71ae1af0..00000000 --- a/nest/src/health.rs +++ /dev/null @@ -1,428 +0,0 @@ -//! Pool-wide pressure tiers + eviction policy. -//! -//! Replaces a flat `idle_timeout` with a tiered classification (Cool → Warm → -//! Hot → Refuse) computed from a [`NestSnapshot`]. The classification drives -//! both local eviction order and the `pressure_state` field humd advertises -//! to the ensemble. -//! -//! Everything in this module is a pure function over plain data so the -//! policy is unit-testable without spinning up a real pool. The pool will -//! build a [`NestSnapshot`] on demand and call [`plan_evictions`] / -//! [`NestSnapshot::health`]. - -use serde::{Deserialize, Serialize}; - -/// Pool-wide pressure tier. Mirrors `ensemble::headroom::Pressure` -/// semantically but lives here because the nest crate computes it from local -/// state and passes it up; the ensemble crate's enum is the wire-side -/// representation. -/// -/// Thresholds (slot occupancy): -/// Cool — < 50% — only idle eviction; no pressure -/// Warm — 50-80% — idle eviction + watch latency -/// Hot — 80-95% — idle eviction + LRU on non-active sigils -/// Refuse — ≥ 95% — refuse new prompts; aggressive idle + LRU; emit signal -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum NestHealth { - Cool, - Warm, - Hot, - Refuse, -} - -impl NestHealth { - /// Compute from a slot-occupancy ratio in `[0.0, 1.0]`. NaN / out-of-range - /// values clamp; an empty nest is reported as `Cool` by the caller (this - /// function never sees `total = 0`). - pub fn from_occupancy(ratio: f32) -> Self { - // NaN propagates through comparisons as false, so explicit guard. - let r = if ratio.is_nan() { - 0.0 - } else { - ratio.clamp(0.0, 1.0) - }; - if r < 0.5 { - NestHealth::Cool - } else if r < 0.8 { - NestHealth::Warm - } else if r < 0.95 { - NestHealth::Hot - } else { - NestHealth::Refuse - } - } - - /// True iff humd should refuse new prompts at this tier. - pub fn refusing(self) -> bool { - matches!(self, NestHealth::Refuse) - } - - /// True iff humd may take new prompts comfortably. - pub fn accepting(self) -> bool { - matches!(self, NestHealth::Cool | NestHealth::Warm) - } -} - -impl Default for NestHealth { - fn default() -> Self { - NestHealth::Cool - } -} - -/// Plain-data snapshot of the pool the eviction policy reads. The actual -/// `Nest` struct (in `pool.rs`) builds one of these on demand. Decoupled so -/// the policy is testable without spinning up a real pool. -#[derive(Debug, Clone, Default)] -pub struct NestSnapshot { - /// `max_procs` from `NestConfig`. - pub total_slots: u32, - /// One entry per currently-resident sid. Order is irrelevant. - pub slots: Vec, -} - -/// One resident sid's view of the world for the eviction policy. -#[derive(Debug, Clone)] -pub struct SlotSnapshot { - pub sid: String, - /// True if this slot has an active listener / is mid-turn. - pub active: bool, - /// Last-touched timestamp in ms-since-epoch. - pub last_touched_ms: i64, - /// True if the cell is ephemeral (PTY / REPL) — these evict cheaply. - pub ephemeral: bool, -} - -impl NestSnapshot { - /// Current occupancy ratio in `[0.0, 1.0]`. Returns `0.0` if - /// `total_slots == 0`. - pub fn occupancy(&self) -> f32 { - if self.total_slots == 0 { - 0.0 - } else { - (self.slots.len() as f32 / self.total_slots as f32).clamp(0.0, 1.0) - } - } - - /// Derive the pool's health tier from occupancy. An empty pool - /// (`total_slots == 0`) is reported as `Cool`. - pub fn health(&self) -> NestHealth { - if self.total_slots == 0 { - return NestHealth::Cool; - } - NestHealth::from_occupancy(self.occupancy()) - } -} - -/// Configuration knobs the policy reads. Lives on `NestConfig` in the real -/// pool; the snapshot includes these so the policy is a pure function. -#[derive(Debug, Clone, Copy)] -pub struct EvictionConfig { - /// Milliseconds since `last_touched_ms` after which a slot counts as idle. - pub idle_threshold_ms: i64, -} - -impl Default for EvictionConfig { - fn default() -> Self { - Self { - idle_threshold_ms: 300_000, // 5 minutes - } - } -} - -/// Eviction target: bring occupancy down to ≤ 80% of total. -fn target_occupied(total_slots: u32) -> usize { - // ceil(total * 0.8) — integer math: (total * 4 + 4) / 5. - ((total_slots as usize) * 4 + 4) / 5 -} - -/// Iff `now_ms - last_touched_ms > idle_threshold_ms`. -fn is_idle(slot: &SlotSnapshot, now_ms: i64, cfg: EvictionConfig) -> bool { - now_ms.saturating_sub(slot.last_touched_ms) > cfg.idle_threshold_ms -} - -/// Decide which sids to evict at the current health tier. Returns sids in -/// the order they should be killed — caller stops early if pressure clears. -/// -/// Policy by tier: -/// Cool — only sids whose `last_touched_ms` is older than -/// `idle_threshold_ms`. -/// Warm — same as Cool. (Pressure isn't high enough to evict mid-turn.) -/// Hot — idle sids + LRU on non-active (inactive) sids until under 80%. -/// Refuse — every ephemeral + every non-active, sorted by oldest first, -/// until under 80%. Active sids are LAST resort. -/// -/// `now_ms` is passed in (not read from the system clock) so the policy is -/// deterministic and testable with explicit timestamps. -pub fn plan_evictions( - snap: &NestSnapshot, - cfg: EvictionConfig, - now_ms: i64, -) -> Vec { - if snap.slots.is_empty() || snap.total_slots == 0 { - return Vec::new(); - } - - let health = snap.health(); - let target = target_occupied(snap.total_slots); - let occupied = snap.slots.len(); - - // Indices over `snap.slots` so we can preserve original ordering when - // sort keys tie (stable sort below relies on this). - let mut idle_indices: Vec = (0..snap.slots.len()) - .filter(|&i| is_idle(&snap.slots[i], now_ms, cfg)) - .collect(); - // Idle list ordered oldest-first so the most-stale sids die first. - idle_indices.sort_by_key(|&i| snap.slots[i].last_touched_ms); - - let mut plan: Vec = Vec::new(); - let mut taken: std::collections::HashSet = std::collections::HashSet::new(); - let mut projected = occupied; - - // 1. Idle eviction — applies at every tier. - for i in idle_indices { - plan.push(snap.slots[i].sid.clone()); - taken.insert(i); - projected = projected.saturating_sub(1); - } - - match health { - NestHealth::Cool | NestHealth::Warm => { - // No additional pressure-based eviction. Idle-only. - } - NestHealth::Hot => { - // LRU on non-active, non-idle slots until projected ≤ target. - if projected > target { - let mut non_active: Vec = (0..snap.slots.len()) - .filter(|&i| !taken.contains(&i) && !snap.slots[i].active) - .collect(); - non_active.sort_by_key(|&i| snap.slots[i].last_touched_ms); - for i in non_active { - if projected <= target { - break; - } - plan.push(snap.slots[i].sid.clone()); - taken.insert(i); - projected -= 1; - } - } - } - NestHealth::Refuse => { - // Ephemeral first (regardless of active), then non-active LRU, - // then active LRU as last resort. All ordered oldest-first - // within each band. - if projected > target { - let mut ephemerals: Vec = (0..snap.slots.len()) - .filter(|&i| !taken.contains(&i) && snap.slots[i].ephemeral) - .collect(); - ephemerals.sort_by_key(|&i| snap.slots[i].last_touched_ms); - for i in ephemerals { - if projected <= target { - break; - } - plan.push(snap.slots[i].sid.clone()); - taken.insert(i); - projected -= 1; - } - } - if projected > target { - let mut non_active: Vec = (0..snap.slots.len()) - .filter(|&i| !taken.contains(&i) && !snap.slots[i].active) - .collect(); - non_active.sort_by_key(|&i| snap.slots[i].last_touched_ms); - for i in non_active { - if projected <= target { - break; - } - plan.push(snap.slots[i].sid.clone()); - taken.insert(i); - projected -= 1; - } - } - if projected > target { - let mut actives: Vec = (0..snap.slots.len()) - .filter(|&i| !taken.contains(&i) && snap.slots[i].active) - .collect(); - actives.sort_by_key(|&i| snap.slots[i].last_touched_ms); - for i in actives { - if projected <= target { - break; - } - plan.push(snap.slots[i].sid.clone()); - taken.insert(i); - projected -= 1; - } - } - } - } - - plan -} - -#[cfg(test)] -mod tests { - use super::*; - - fn slot(sid: &str, active: bool, last: i64, ephemeral: bool) -> SlotSnapshot { - SlotSnapshot { - sid: sid.to_string(), - active, - last_touched_ms: last, - ephemeral, - } - } - - #[test] - fn health_threshold_bands() { - // Below 0.5 → Cool. - assert_eq!(NestHealth::from_occupancy(0.0), NestHealth::Cool); - assert_eq!(NestHealth::from_occupancy(0.49), NestHealth::Cool); - // At 0.5 → Warm. - assert_eq!(NestHealth::from_occupancy(0.5), NestHealth::Warm); - assert_eq!(NestHealth::from_occupancy(0.79), NestHealth::Warm); - // At 0.8 → Hot. - assert_eq!(NestHealth::from_occupancy(0.8), NestHealth::Hot); - assert_eq!(NestHealth::from_occupancy(0.94), NestHealth::Hot); - // At 0.95 → Refuse. - assert_eq!(NestHealth::from_occupancy(0.95), NestHealth::Refuse); - assert_eq!(NestHealth::from_occupancy(1.0), NestHealth::Refuse); - - // Clamp behavior. - assert_eq!(NestHealth::from_occupancy(-0.1), NestHealth::Cool); - assert_eq!(NestHealth::from_occupancy(2.0), NestHealth::Refuse); - assert_eq!(NestHealth::from_occupancy(f32::NAN), NestHealth::Cool); - } - - #[test] - fn cool_only_evicts_idle() { - // 3 of 10 slots → 30% → Cool. Two old, one fresh. - let now = 10_000_i64; - let snap = NestSnapshot { - total_slots: 10, - slots: vec![ - slot("old-a", false, 1_000, false), // 9s old - slot("old-b", false, 2_000, false), // 8s old - slot("fresh", false, 9_500, false), // 0.5s old - ], - }; - assert_eq!(snap.health(), NestHealth::Cool); - - let cfg = EvictionConfig { - idle_threshold_ms: 1_000, - }; - let plan = plan_evictions(&snap, cfg, now); - - assert_eq!(plan.len(), 2); - assert!(plan.contains(&"old-a".to_string())); - assert!(plan.contains(&"old-b".to_string())); - assert!(!plan.contains(&"fresh".to_string())); - // Stable LRU order: oldest first. - assert_eq!(plan, vec!["old-a", "old-b"]); - } - - #[test] - fn warm_behaves_like_cool() { - // 5 of 10 slots → 50% → Warm. Two idle, three fresh. - let now = 10_000_i64; - let snap = NestSnapshot { - total_slots: 10, - slots: vec![ - slot("old-a", false, 1_000, false), - slot("old-b", false, 2_000, false), - slot("fresh-1", false, 9_500, false), - slot("fresh-2", false, 9_600, false), - slot("fresh-3", false, 9_700, false), - ], - }; - assert_eq!(snap.health(), NestHealth::Warm); - - let cfg = EvictionConfig { - idle_threshold_ms: 1_000, - }; - let plan = plan_evictions(&snap, cfg, now); - - // Warm: idle only, no LRU pressure. - assert_eq!(plan, vec!["old-a", "old-b"]); - } - - #[test] - fn hot_adds_lru_for_inactive() { - // 10 of 10 slots → 100% → Refuse tier exercises the non-active-LRU - // path (same path the Hot tier uses). None are idle, all inactive, - // no ephemerals. Target = ceil(10 * 0.8) = 8, so 2 evictions; the - // two oldest must come first. - let now = 10_000_i64; - let mut slots = Vec::new(); - for i in 0..10 { - // last_touched_ms = now (fresh), but per-sid offset so LRU is - // unambiguous: lower i = older. - slots.push(slot( - &format!("s{i}"), - false, - now - (10 - i as i64), // s0 oldest, s9 newest - false, - )); - } - let snap = NestSnapshot { - total_slots: 10, - slots, - }; - assert_eq!(snap.health(), NestHealth::Refuse); - - // Threshold high enough that no slot counts as "idle". This forces - // the LRU-for-inactive path, not the idle path. - let cfg = EvictionConfig { - idle_threshold_ms: 60_000, - }; - let plan = plan_evictions(&snap, cfg, now); - - // Exactly two evictions: oldest first. - assert_eq!(plan.len(), 2); - assert_eq!(plan, vec!["s0".to_string(), "s1".to_string()]); - } - - #[test] - fn refuse_prefers_ephemeral() { - // 10 of 10 slots, all active, none idle. Two ephemeral. Target = 8, - // so 2 evictions; ephemerals come first regardless of LRU order - // among non-ephemerals. - let now = 10_000_i64; - let mut slots = Vec::new(); - for i in 0..10 { - // All very recent; ephemeral on indices 4 and 7. - let eph = i == 4 || i == 7; - slots.push(slot( - &format!("s{i}"), - true, // all active - now - (10 - i as i64), - eph, - )); - } - let snap = NestSnapshot { - total_slots: 10, - slots, - }; - assert_eq!(snap.health(), NestHealth::Refuse); - - let cfg = EvictionConfig { - idle_threshold_ms: 60_000, - }; - let plan = plan_evictions(&snap, cfg, now); - - assert_eq!(plan.len(), 2); - // Ephemerals come first; among them, the older one (s4) precedes s7. - assert_eq!(plan, vec!["s4".to_string(), "s7".to_string()]); - } - - #[test] - fn empty_snapshot_yields_empty_plan() { - let snap = NestSnapshot::default(); - assert_eq!(snap.total_slots, 0); - assert!(snap.slots.is_empty()); - assert_eq!(snap.occupancy(), 0.0); - assert_eq!(snap.health(), NestHealth::Cool); - - let plan = plan_evictions(&snap, EvictionConfig::default(), 0); - assert!(plan.is_empty()); - } -} diff --git a/nest/src/lib.rs b/nest/src/lib.rs index f314fe70..bf307af1 100644 --- a/nest/src/lib.rs +++ b/nest/src/lib.rs @@ -1,13 +1,4 @@ -//! nest — bee crates: traits for WorkerBee (produce compute) and -//! ForagerBee (translate outside wire ↔ thrum). A `Nest` keyed by -//! `pool_key` (== sid) holds at most one `Cell` per key. Each cell -//! wraps a child process spawned via a `WorkerBee` impl. The daemon -//! binary registers `Listener`s on a cell to receive parsed stream -//! events. -//! -//! These traits are the Rust SDK for building bees that handshake with -//! humd over thrum. Authors who don't want Rust can implement the same -//! wire role directly via the thrum-clients libs. +//! WorkerBee trait + Cell shape + lifecycle/limits/metrics submodules. use std::collections::HashMap; use std::sync::Arc; @@ -18,20 +9,9 @@ use serde_json::Value; use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; -pub mod lifecycle; // own + supervise tokio::process::Child correctly -pub mod mock; -pub mod pool; - -// Resource-oriented primitives for the Cell as a system resource. -// Filled in by parallel work; declared together so contributors don't race -// on this file. See each module's docstring for scope. -pub mod metrics; // per-cell observability (RSS, CPU, fds) -pub mod limits; // per-cell OS-level caps (rlimit, cgroups) -pub mod budget; // per-cell soft caps (tokens, tool-call rates) -pub mod health; // pool-wide pressure tiers + eviction policy - -pub use mock::MockWorkerBee; -pub use pool::Nest; +pub mod lifecycle; +pub mod metrics; +pub mod limits; /// High-level spec the daemon hands to a worker bee. The bee is /// responsible for turning this into whatever command line / process @@ -160,32 +140,6 @@ pub trait WorkerBee: Send + Sync { async fn spawn(&self, spec: SpawnSpec) -> Result; } -/// A ForagerBee translates an outside wire (OpenAI, Anthropic, custom -/// HTTP, etc.) into thrum tones and back. It carries `chi:"prompt"` in -/// and `chi:"chunk"` / `chi:"finish"` / `chi:"tool-call"` out, against -/// some external surface. -/// -/// Hybrid bees that are both worker and forager simply implement both -/// traits — there is no constraint against it. -#[async_trait] -pub trait ForagerBee: Send + Sync { - /// Symbolic name for the external surface this forager translates - /// (e.g. "openai-v1", "anthropic-messages"). - fn surface(&self) -> &str; -} - -/// Listener receives parsed stream events for one session bound to a -/// cell. The daemon binary is responsible for translating Petals into -/// thrum chunks. -#[async_trait] -pub trait Listener: Send + Sync { - fn session_id(&self) -> &str; - async fn on_petal(&self, kind: &str, payload: Value); - async fn on_cell(&self, nest_id: &str, model: &str, tools: Vec); - async fn on_wilt(&self, finish_reason: &str, usage: Option, provider_meta: Value); - async fn on_thorn(&self, wound: &str); -} - /// A non-text addition to a prompt — image, audio, pdf, etc. Carried /// alongside the text content so workers can hand the model both at /// once. `data` is base64 for inline; `url` is the alternative (worker diff --git a/nest/src/lifecycle.rs b/nest/src/lifecycle.rs index c1d3264c..336af52a 100644 --- a/nest/src/lifecycle.rs +++ b/nest/src/lifecycle.rs @@ -7,8 +7,11 @@ use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use tracing::warn; +use crate::metrics; + const REAP_TIMEOUT: Duration = Duration::from_secs(5); const SIGKILL_EXIT: i32 = 137; +const SAMPLE_INTERVAL: Duration = Duration::from_secs(10); pub fn supervise(mut child: AsyncGroupChild) -> (oneshot::Receiver, CancellationToken) { let cancel = CancellationToken::new(); @@ -16,6 +19,35 @@ pub fn supervise(mut child: AsyncGroupChild) -> (oneshot::Receiver, Cancell let (tx_exit, rx_exit) = oneshot::channel(); let pid = child.inner().id(); + if let Some(pid) = pid { + let spawned_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64).unwrap_or(0); + let (mut rx, sampler) = metrics::spawn_sampler(pid, spawned_at_ms, SAMPLE_INTERVAL); + let cancel_for_sampler = cancel_for_task.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + biased; + _ = cancel_for_sampler.cancelled() => break, + snap = rx.recv() => match snap { + Some(s) => { + if let Some(rss) = s.rss_bytes { + ::metrics::gauge!("hum_cell_rss_bytes", "pid" => pid.to_string()).set(rss as f64); + } + if let Some(cpu) = s.cpu_ms { + ::metrics::gauge!("hum_cell_cpu_ms", "pid" => pid.to_string()).set(cpu as f64); + } + } + None => break, + } + } + } + sampler.abort(); + ::metrics::gauge!("hum_cell_rss_bytes", "pid" => pid.to_string()).set(0.0); + }); + } + tokio::spawn(async move { let code = tokio::select! { biased; @@ -32,7 +64,7 @@ pub fn supervise(mut child: AsyncGroupChild) -> (oneshot::Receiver, Cancell } async fn kill_and_reap(child: &mut AsyncGroupChild, pid: Option) -> i32 { - metrics::counter!("hum_cell_kills_total").increment(1); + ::metrics::counter!("hum_cell_kills_total").increment(1); if let Err(e) = child.kill().await { warn!(target: "nest::lifecycle", pid = ?pid, err = %e, "cell.kill.signal_failed"); } @@ -81,26 +113,19 @@ mod tests { let marker = std::env::temp_dir().join(format!("hum-tree-kill-{}", std::process::id())); let _ = std::fs::remove_file(&marker); let marker_path = marker.to_string_lossy().to_string(); - let script = format!( - "sh -c 'sleep 60 & echo $! > {marker_path}; wait'" - ); + let script = format!("sh -c 'sleep 60 & echo $! > {marker_path}; wait'"); let child = Command::new("sh").arg("-c").arg(&script) .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()) .group_spawn().expect("spawn sh"); let (_rx, cancel) = supervise(child); - - // wait for the grandchild pid to appear for _ in 0..30 { if marker.exists() { break; } tokio::time::sleep(Duration::from_millis(100)).await; } let grandchild_pid: u32 = std::fs::read_to_string(&marker) .expect("grandchild marker").trim().parse().expect("pid parse"); - cancel.cancel(); tokio::time::sleep(Duration::from_millis(300)).await; - - // Sending signal 0 returns ESRCH if the process is gone. let alive = unsafe { libc::kill(grandchild_pid as i32, 0) } == 0; let _ = std::fs::remove_file(&marker); assert!(!alive, "grandchild {grandchild_pid} survived tree-kill"); diff --git a/nest/src/mock.rs b/nest/src/mock.rs deleted file mode 100644 index afaf4036..00000000 --- a/nest/src/mock.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! mock — a test-only WorkerBee that emits canned stream-json events. -//! -//! Sim tests can't spawn real subprocesses. `MockWorkerBee` returns a -//! [`Cell`] whose `events` channel emits a deterministic sequence shaped -//! exactly like `claude -p --output-format stream-json` would, so the -//! daemon's listener bridge fires through the same code path. - -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Result; -use async_trait::async_trait; -use serde_json::{json, Value}; -use tokio::sync::{mpsc, oneshot, Mutex}; - -use crate::{Cell, SpawnSpec, WorkerBee}; - -/// A WorkerBee that produces canned events instead of running a subprocess. -pub struct MockWorkerBee { - /// Override the canned output text. Defaults to "HELLO". - pub text: String, - /// Optional artificial latency between events (Duration::ZERO default). - pub event_delay: Duration, - /// If true, also push a `tool_use` block. Defaults false. - pub with_tool: bool, -} - -impl Default for MockWorkerBee { - fn default() -> Self { - Self { - text: "HELLO".to_string(), - event_delay: Duration::ZERO, - with_tool: false, - } - } -} - -#[async_trait] -impl WorkerBee for MockWorkerBee { - fn ephemeral(&self) -> bool { false } - - async fn spawn(&self, spec: SpawnSpec) -> Result { - let (tx_in, mut rx_in) = mpsc::channel::(64); - let (tx_evt, rx_evt) = mpsc::channel::(256); - let (tx_exit, rx_exit) = oneshot::channel::(); - - // stdin drain — the worker doesn't care what the daemon writes back. - tokio::spawn(async move { - while rx_in.recv().await.is_some() { /* /dev/null */ } - }); - - let text = self.text.clone(); - let delay = self.event_delay; - let with_tool = self.with_tool; - let sid = spec.sid.clone(); - let model = spec.model_id.clone(); - - tokio::spawn(async move { - // Build the sequence first so we can iterate uniformly. - let mut events: Vec = Vec::new(); - events.push(json!({ - "type": "system", - "subtype": "init", - "session_id": sid, - "model": model, - "tools": [], - })); - events.push(json!({ - "type": "content_block_start", - "index": 0, - "content_block": { "type": "text" }, - })); - events.push(json!({ - "type": "content_block_delta", - "index": 0, - "delta": { "type": "text_delta", "text": text }, - })); - events.push(json!({ - "type": "content_block_stop", - "index": 0, - })); - if with_tool { - events.push(json!({ - "type": "content_block_start", - "index": 1, - "content_block": { - "type": "tool_use", - "id": "toolu_mock_1", - "name": "mock_tool", - "input": {}, - }, - })); - events.push(json!({ - "type": "content_block_stop", - "index": 1, - })); - } - events.push(json!({ - "type": "result", - "subtype": "success", - "stop_reason": "end_turn", - "session_id": sid, - "usage": {}, - })); - - for evt in events { - if !delay.is_zero() { - tokio::time::sleep(delay).await; - } - if tx_evt.send(evt).await.is_err() { - // Receiver dropped — bail; the test is gone. - let _ = tx_exit.send(0); - return; - } - } - let _ = tx_exit.send(0); - }); - - Ok(Cell { - pid: None, - stdin: tx_in, - events: Arc::new(Mutex::new(rx_evt)), - exited: rx_exit, - ephemeral: false, - cancel: tokio_util::sync::CancellationToken::new(), - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn emits_canned_sequence_with_default_text() { - let bee = MockWorkerBee::default(); - let spec = SpawnSpec::new("sid-mock", "claude-haiku-4-5", "/tmp"); - let cell = bee.spawn(spec).await.unwrap(); - - let mut events = Vec::new(); - { - let mut rx = cell.events.lock().await; - while let Some(v) = rx.recv().await { - events.push(v); - } - } - - // 5 events in default mode. - assert_eq!(events.len(), 5); - assert_eq!(events[0]["type"], "system"); - assert_eq!(events[0]["session_id"], "sid-mock"); - assert_eq!(events[0]["model"], "claude-haiku-4-5"); - - assert_eq!(events[1]["type"], "content_block_start"); - assert_eq!(events[1]["content_block"]["type"], "text"); - - assert_eq!(events[2]["type"], "content_block_delta"); - assert_eq!(events[2]["delta"]["type"], "text_delta"); - assert_eq!(events[2]["delta"]["text"], "HELLO"); - - assert_eq!(events[3]["type"], "content_block_stop"); - - assert_eq!(events[4]["type"], "result"); - assert_eq!(events[4]["stop_reason"], "end_turn"); - - let code = cell.exited.await.unwrap(); - assert_eq!(code, 0); - } - - #[tokio::test] - async fn custom_text_appears_in_delta() { - let bee = MockWorkerBee { text: "ahoy world".into(), ..Default::default() }; - let spec = SpawnSpec::new("s", "m", "/"); - let cell = bee.spawn(spec).await.unwrap(); - let mut rx = cell.events.lock().await; - let mut saw_text = None; - while let Some(v) = rx.recv().await { - if v["type"] == "content_block_delta" { - saw_text = v["delta"]["text"].as_str().map(|s| s.to_string()); - } - } - assert_eq!(saw_text.as_deref(), Some("ahoy world")); - } - - #[tokio::test] - async fn with_tool_inserts_tool_use_block() { - let bee = MockWorkerBee { with_tool: true, ..Default::default() }; - let spec = SpawnSpec::new("s", "m", "/"); - let cell = bee.spawn(spec).await.unwrap(); - let mut rx = cell.events.lock().await; - let mut kinds = Vec::new(); - while let Some(v) = rx.recv().await { - kinds.push(v["type"].as_str().unwrap_or("").to_string()); - if v["type"] == "content_block_start" { - if let Some(cb) = v.get("content_block") { - if cb["type"] == "tool_use" { - assert_eq!(cb["name"], "mock_tool"); - } - } - } - } - // Two extra blocks vs default. - assert_eq!(kinds.len(), 7); - assert_eq!(kinds.last().map(String::as_str), Some("result")); - } - - #[tokio::test] - async fn stdin_drains_silently() { - let bee = MockWorkerBee::default(); - let cell = bee.spawn(SpawnSpec::new("s", "m", "/")).await.unwrap(); - // Should not panic / block. - cell.stdin.send("ignored line".into()).await.unwrap(); - cell.stdin.send(crate::encode_prompt("hi")).await.unwrap(); - } - - /// End-to-end with a Listener — the daemon's bridge sees text-delta + finish. - #[tokio::test] - async fn listener_bridge_sees_delta_and_finish() { - use crate::Listener; - use std::sync::Mutex as StdMutex; - - struct Captor { - sid: String, - petals: StdMutex>, - wilted: StdMutex>, - } - - #[async_trait::async_trait] - impl Listener for Captor { - fn session_id(&self) -> &str { &self.sid } - async fn on_petal(&self, kind: &str, payload: Value) { - self.petals.lock().unwrap().push((kind.into(), payload)); - } - async fn on_cell(&self, _nest_id: &str, _model: &str, _tools: Vec) {} - async fn on_wilt(&self, finish_reason: &str, _usage: Option, _meta: Value) { - *self.wilted.lock().unwrap() = Some(finish_reason.into()); - } - async fn on_thorn(&self, _wound: &str) {} - } - - let captor = Arc::new(Captor { - sid: "sid-bridge".into(), - petals: StdMutex::new(Vec::new()), - wilted: StdMutex::new(None), - }); - let listener: Arc = captor.clone(); - - let bee = MockWorkerBee::default(); - let cell = bee.spawn(SpawnSpec::new("sid-bridge", "claude-sonnet-4-6", "/")).await.unwrap(); - - // Minimal bridge — mirrors what the daemon binary does: pump events - // off the cell, route by `type`, and call the listener. - let events_arc = cell.events.clone(); - let mut rx = events_arc.lock().await; - while let Some(v) = rx.recv().await { - let kind = v["type"].as_str().unwrap_or("").to_string(); - match kind.as_str() { - "content_block_delta" => { - listener.on_petal("text_delta", v.clone()).await; - } - "result" => { - let reason = v["stop_reason"].as_str().unwrap_or("").to_string(); - listener.on_wilt(&reason, v.get("usage").cloned(), Value::Null).await; - } - _ => {} - } - } - - let petals = captor.petals.lock().unwrap().clone(); - assert_eq!(petals.len(), 1); - assert_eq!(petals[0].0, "text_delta"); - assert_eq!(petals[0].1["delta"]["text"], "HELLO"); - - let wilted = captor.wilted.lock().unwrap().clone(); - assert_eq!(wilted.as_deref(), Some("end_turn")); - } -} diff --git a/nest/src/pool.rs b/nest/src/pool.rs deleted file mode 100644 index edac6671..00000000 --- a/nest/src/pool.rs +++ /dev/null @@ -1,477 +0,0 @@ -//! Nest — the cell pool. Mirrors TS `nest/nest.ts` (Nest class). -//! -//! Owns a map of `pool_key -> Cell`, dispatches stdin writes (`murmur`, -//! `reply`, `interrupt`), evicts on idle, enforces `max_procs`, and routes -//! events from each cell to a set of `Listener`s. Stream parsing — the -//! dispatchLine switch — lives here so listeners get typed petal callbacks. -//! -//! v0 simplifications: -//! - no `needsRespawn` handling (no Hum table in the rust crate) -//! - no permission ask hold (the daemon binary owns permits) -//! - no fading-cell coordination -//! - drift/drone hooks omitted - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{anyhow, Result}; -use serde_json::{json, Value}; -use tokio::sync::{Mutex, RwLock}; -use tokio::task::JoinHandle; -use tracing::{info, trace}; - -use crate::{encode_cancel, encode_prompt, encode_prompt_with_attachments, encode_tool_result, Attachment, Listener, Cell, SpawnSpec, WorkerBee}; - -pub struct NestConfig { - pub max_procs: usize, - pub idle_timeout: Duration, -} - -impl Default for NestConfig { - fn default() -> Self { - Self { - max_procs: 8, - idle_timeout: Duration::from_secs(300), - } - } -} - -struct CellSlot { - cell: Cell, - listeners: Mutex>>, - active_sid: Mutex>, - #[allow(dead_code)] - pool_key: String, - /// Held so the dispatch task drops cleanly when the slot is removed. - _dispatch: JoinHandle<()>, - idle_handle: Mutex>>, -} - -pub struct Nest { - cfg: NestConfig, - worker_pipe: Arc, - worker_pty: Arc, - slots: RwLock>>, -} - -impl Nest { - pub fn new(cfg: NestConfig, pipe: Arc, pty: Arc) -> Self { - Self { - cfg, - worker_pipe: pipe, - worker_pty: pty, - slots: RwLock::new(HashMap::new()), - } - } - - /// Subscribe `listener` to the cell at `pool_key`, spawning one if absent. - /// `use_pty` picks the worker bee. Mirrors TS `awaken`. - pub async fn awaken( - self: &Arc, - pool_key: &str, - listener: Arc, - spec: SpawnSpec, - use_pty: bool, - ) -> Result<()> { - // Fast path: existing cell. - { - let slots = self.slots.read().await; - if let Some(slot) = slots.get(pool_key) { - slot.listeners - .lock() - .await - .insert(listener.session_id().to_string(), listener.clone()); - if let Some(h) = slot.idle_handle.lock().await.take() { - h.abort(); - } - listener.on_petal("stream_start", json!({})).await; - return Ok(()); - } - } - - // Spawn path. Enforce max_procs; evict an idle slot if needed. - self.maybe_evict_one().await; - - let worker = if use_pty { - self.worker_pty.clone() - } else { - self.worker_pipe.clone() - }; - let cell = worker.spawn(spec).await?; - let ephemeral = cell.ephemeral; - let pool_key_owned = pool_key.to_string(); - - // Move the events receiver out for the dispatch task. - let events = cell.events.clone(); - let slot_pre = SlotInner { - pool_key: pool_key_owned.clone(), - listeners: Arc::new(Mutex::new(HashMap::new())), - active_sid: Arc::new(Mutex::new(None)), - ephemeral, - }; - slot_pre - .listeners - .lock() - .await - .insert(listener.session_id().to_string(), listener.clone()); - - let dispatch = tokio::spawn(dispatch_loop(slot_pre.clone(), events)); - - let slot = Arc::new(CellSlot { - cell, - listeners: Mutex::new({ - let mut m = HashMap::new(); - m.insert(listener.session_id().to_string(), listener.clone()); - m - }), - active_sid: Mutex::new(None), - pool_key: pool_key_owned.clone(), - _dispatch: dispatch, - idle_handle: Mutex::new(None), - }); - - self.slots - .write() - .await - .insert(pool_key_owned.clone(), slot); - - info!(target: "nest", pool_key = %pool_key_owned, ephemeral, "nest.awakened"); - listener.on_petal("stream_start", json!({})).await; - Ok(()) - } - - /// Send a user prompt to the active cell (TS `murmur`). Text-only - /// shortcut; multimodal callers use `murmur_with_attachments`. - pub async fn murmur(&self, session_id: &str, pool_key: &str, content: &str) -> Result<()> { - self.murmur_with_attachments(session_id, pool_key, content, &[]).await - } - - /// Send a user prompt that may carry image / audio / pdf - /// attachments alongside the text. nest stays format-neutral - /// (no worker-specific knowledge); the underlying encoding maps - /// known attachment kinds into content blocks the receiving - /// stdin shape expects and surfaces unknown kinds as text - /// annotations. - pub async fn murmur_with_attachments( - &self, - session_id: &str, - pool_key: &str, - content: &str, - attachments: &[Attachment], - ) -> Result<()> { - let slots = self.slots.read().await; - let slot = slots.get(pool_key).ok_or_else(|| anyhow!("no cell"))?; - *slot.active_sid.lock().await = Some(session_id.to_string()); - let encoded = if attachments.is_empty() { - encode_prompt(content) - } else { - encode_prompt_with_attachments(content, attachments) - }; - slot.cell - .stdin - .send(encoded) - .await - .map_err(|e| anyhow!("stdin closed: {e}"))?; - trace!(target: "nest", %pool_key, sid = %session_id, attachments = attachments.len(), "nest.murmured"); - Ok(()) - } - - /// Send a tool_result back to the active cell (TS `reply`). - pub async fn reply( - &self, - session_id: &str, - pool_key: &str, - tool_use_id: &str, - result: &str, - ) -> Result<()> { - let slots = self.slots.read().await; - let slot = slots.get(pool_key).ok_or_else(|| anyhow!("no cell"))?; - *slot.active_sid.lock().await = Some(session_id.to_string()); - slot.cell - .stdin - .send(encode_tool_result(tool_use_id, result)) - .await - .map_err(|e| anyhow!("stdin closed: {e}"))?; - Ok(()) - } - - /// Mid-turn interrupt. Pipe-mode only; PTY ignores per TS. - pub async fn interrupt(&self, pool_key: &str, request_id: &str) -> Result<()> { - let slots = self.slots.read().await; - let slot = slots.get(pool_key).ok_or_else(|| anyhow!("no cell"))?; - if slot.cell.ephemeral { - trace!(target: "nest", %pool_key, "pty.stdin.ignored type=control_cancel_request"); - return Ok(()); - } - slot.cell - .stdin - .send(encode_cancel(request_id)) - .await - .map_err(|e| anyhow!("stdin closed: {e}"))?; - Ok(()) - } - - /// Detach `session_id` from `pool_key`. If no listeners remain and an - /// idle_timeout is configured, schedule eviction. - pub async fn hush(self: &Arc, session_id: &str, pool_key: &str) { - let slots = self.slots.read().await; - let Some(slot) = slots.get(pool_key).cloned() else { return }; - drop(slots); - slot.listeners.lock().await.remove(session_id); - { - let mut active = slot.active_sid.lock().await; - if active.as_deref() == Some(session_id) { - *active = None; - } - } - - if !slot.listeners.lock().await.is_empty() { - return; - } - if self.cfg.idle_timeout.is_zero() { - return; - } - - let nest_w = Arc::downgrade(self); - let pk = pool_key.to_string(); - let timeout = self.cfg.idle_timeout; - let handle = tokio::spawn(async move { - tokio::time::sleep(timeout).await; - let Some(nest) = nest_w.upgrade() else { return }; - let mut slots = nest.slots.write().await; - if let Some(slot) = slots.get(&pk) { - if slot.listeners.lock().await.is_empty() { - trace!(target: "nest", pool_key = %pk, "nest.idle"); - slot.cell.cancel.cancel(); - slots.remove(&pk); - } - } - }); - *slot.idle_handle.lock().await = Some(handle); - } - - /// Force-kill a cell (TS `fell` when last listener gone). - pub async fn fell(&self, pool_key: &str) { - let mut slots = self.slots.write().await; - if let Some(slot) = slots.remove(pool_key) { - trace!(target: "nest", %pool_key, "nest.felled"); - slot.cell.cancel.cancel(); - } - } - - /// Kill every cell (TS `silence`). - pub async fn silence(&self) { - let mut slots = self.slots.write().await; - for (_, slot) in slots.drain() { - slot.cell.cancel.cancel(); - } - } - - async fn maybe_evict_one(&self) { - let mut slots = self.slots.write().await; - if slots.len() < self.cfg.max_procs { - return; - } - let mut evict_key: Option = None; - for (key, slot) in slots.iter() { - let listeners = slot.listeners.lock().await; - let active = slot.active_sid.lock().await; - if listeners.is_empty() && active.is_none() { - evict_key = Some(key.clone()); - break; - } - } - if let Some(k) = evict_key { - if let Some(slot) = slots.remove(&k) { - trace!(target: "nest", pool_key = %k, "nest.evicted reason=maxActiveCells"); - slot.cell.cancel.cancel(); - } - } - } -} - -/// Lightweight handle the dispatch task uses to find listeners. Avoids -/// looping in `CellSlot` (which holds the JoinHandle of this task — would -/// be a self-reference). -#[derive(Clone)] -struct SlotInner { - pool_key: String, - listeners: Arc>>>, - active_sid: Arc>>, - ephemeral: bool, -} - -async fn dispatch_loop(slot: SlotInner, events: Arc>>) { - loop { - let next = { - let mut guard = events.lock().await; - guard.recv().await - }; - let Some(mut msg) = next else { break }; - - // Flatten {type:"stream_event", event:{...}} → {...} - if msg.get("type").and_then(|v| v.as_str()) == Some("stream_event") { - if let Some(inner) = msg.get("event").cloned() { - msg = inner; - } - } - - let mtype = msg.get("type").and_then(|v| v.as_str()).unwrap_or("").to_string(); - trace!(target: "nest", pool_key = %slot.pool_key, ty = %mtype, "stream.msg.received"); - - // system.init — broadcast to every listener so they learn the - // upstream Claude session_id and the model. - if mtype == "system" && msg.get("subtype").and_then(|v| v.as_str()) == Some("init") { - let sid = msg.get("session_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let model = msg.get("model").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let tools: Vec = msg - .get("tools") - .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|t| t.as_str().map(String::from)).collect()) - .unwrap_or_default(); - let listeners = slot.listeners.lock().await.clone(); - for (_, l) in listeners { - l.on_cell(&sid, &model, tools.clone()).await; - } - continue; - } - - // Pick the listener: active sid first, else any. - let listener = { - let active = slot.active_sid.lock().await.clone(); - let map = slot.listeners.lock().await; - active - .as_ref() - .and_then(|sid| map.get(sid).cloned()) - .or_else(|| map.values().next().cloned()) - }; - let Some(listener) = listener else { continue }; - - match mtype.as_str() { - "content_block_start" => { - let idx = msg.get("index").cloned().unwrap_or(Value::Null); - let block = msg.get("content_block").cloned().unwrap_or_else(|| json!({})); - let bt = block.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match bt { - "thinking" => listener.on_petal("reasoning_start", json!({"id": idx})).await, - "text" => listener.on_petal("text_start", json!({"id": idx})).await, - "tool_use" => { - listener - .on_petal( - "tool_input_start", - json!({ - "toolCallId": block.get("id"), - "toolName": block.get("name"), - }), - ) - .await; - } - _ => {} - } - } - "content_block_delta" => { - let delta = msg.get("delta").cloned().unwrap_or_else(|| json!({})); - match delta.get("type").and_then(|v| v.as_str()).unwrap_or("") { - "thinking_delta" => { - listener - .on_petal("reasoning_delta", json!({"delta": delta.get("thinking")})) - .await - } - "text_delta" => { - listener - .on_petal("text_delta", json!({"delta": delta.get("text")})) - .await - } - "input_json_delta" => { - listener - .on_petal( - "tool_input_delta", - json!({"partialJson": delta.get("partial_json")}), - ) - .await - } - _ => {} - } - } - "content_block_stop" => { - listener - .on_petal("content_block_stop", json!({"blockIdx": msg.get("index")})) - .await; - } - "assistant" => { - if let Some(content) = msg - .get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_array()) - { - for block in content { - if block.get("type").and_then(|v| v.as_str()) == Some("tool_use") { - listener - .on_petal( - "tool_call", - json!({ - "toolCallId": block.get("id"), - "toolName": block.get("name"), - "input": block.get("input"), - }), - ) - .await; - } - } - } - } - "user" => { - if let Some(content) = msg - .get("message") - .and_then(|m| m.get("content")) - .and_then(|c| c.as_array()) - { - for block in content { - if block.get("type").and_then(|v| v.as_str()) == Some("tool_result") { - let tool_use_id = - block.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or(""); - let result = match block.get("content") { - Some(Value::String(s)) => s.clone(), - Some(Value::Array(parts)) => parts - .iter() - .filter_map(|p| p.get("text").and_then(|v| v.as_str())) - .collect::>() - .join("\n"), - _ => String::new(), - }; - listener - .on_petal( - "tool_result", - json!({"toolUseId": tool_use_id, "result": result}), - ) - .await; - } - } - } - } - "result" => { - let finish = msg - .get("stop_reason") - .and_then(|v| v.as_str()) - .unwrap_or("stop") - .to_string(); - let usage = msg.get("usage").cloned(); - let meta = json!({ - "sessionId": msg.get("session_id"), - "cost": msg.get("total_cost_usd"), - }); - listener.on_wilt(&finish, usage, meta).await; - let mut active = slot.active_sid.lock().await; - if let Some(sid) = active.take() { - slot.listeners.lock().await.remove(&sid); - } - if slot.ephemeral { - trace!(target: "nest", pool_key = %slot.pool_key, "nest.turn.end.kept-alive"); - } - } - _ => {} - } - } - trace!(target: "nest", pool_key = %slot.pool_key, "nest.dispatch.eof"); -} diff --git a/thrumd/Cargo.toml b/thrumd/Cargo.toml index 014751a9..2aa40391 100644 --- a/thrumd/Cargo.toml +++ b/thrumd/Cargo.toml @@ -17,3 +17,6 @@ anyhow = { workspace = true } parking_lot = { workspace = true } uuid = { version = "1", features = ["v4"] } governor = "0.6" + +[dev-dependencies] +tokio = { workspace = true } diff --git a/thrumd/tests/accept_rate_limit.rs b/thrumd/tests/accept_rate_limit.rs new file mode 100644 index 00000000..51afacd7 --- /dev/null +++ b/thrumd/tests/accept_rate_limit.rs @@ -0,0 +1,17 @@ +use governor::{Quota, RateLimiter}; +use std::num::NonZeroU32; +use std::time::{Duration, Instant}; + +#[tokio::test] +async fn rate_limiter_paces_accepts() { + let limiter = RateLimiter::direct(Quota::per_second(NonZeroU32::new(10).unwrap())); + let start = Instant::now(); + for _ in 0..20 { + limiter.until_ready().await; + } + let elapsed = start.elapsed(); + assert!( + elapsed >= Duration::from_millis(900), + "20 acquisitions at 10/s should take ~1s, took {elapsed:?}", + ); +} From 9de15ca4092c3b549a01c2eff132d244e053f43f Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 17:25:50 +0000 Subject: [PATCH 05/18] butcher remaining debt: limits wired, mcp_bridge.sid + is_code_file purged, flaky test budgeted - nest::ResourceLimits now actually applied: claude-cli calls apply_pre_exec on the std::process::Command (via cmd.as_std_mut()) before group_spawn. SpawnSpec.resource_limits stops being decorative. - CatalogueSlot.sid: stored but never read. Field + setter param dropped. - humfs::tools::read::is_code_file: production-unused fn with tests testing nothing the codebase uses. Both purged. - partition_then_heal_converges_wane: budget raised 1s -> 10s so the test is no longer load-flaky under parallel runs. cargo check + cargo check --tests: zero warnings. --- hives/claude-cli/src/lib.rs | 3 +++ hives/common/src/mcp_bridge.rs | 10 +++------- hives/common/src/serve.rs | 2 +- hives/humfs/src/tools/read.rs | 33 --------------------------------- sim/tests/partition_and_heal.rs | 7 ++----- 5 files changed, 9 insertions(+), 46 deletions(-) diff --git a/hives/claude-cli/src/lib.rs b/hives/claude-cli/src/lib.rs index b622733c..8aeab946 100644 --- a/hives/claude-cli/src/lib.rs +++ b/hives/claude-cli/src/lib.rs @@ -130,6 +130,9 @@ impl WorkerBee for ClaudeCliWorker { .stdout(Stdio::piped()) .stderr(Stdio::piped()); + spec.resource_limits.apply_pre_exec(cmd.as_std_mut()) + .with_context(|| "apply resource limits")?; + let mut child = cmd.group_spawn().with_context(|| format!("spawn {cli}"))?; let pid = child.inner().id(); let mut stdin = child.inner().stdin.take().context("missing stdin")?; diff --git a/hives/common/src/mcp_bridge.rs b/hives/common/src/mcp_bridge.rs index 84e77841..895b72bd 100644 --- a/hives/common/src/mcp_bridge.rs +++ b/hives/common/src/mcp_bridge.rs @@ -42,11 +42,8 @@ pub struct McpBridge { ship_tool_call: Arc, } -/// Catalogue for a session — set by the worker on each chi:"prompt" -/// arrival from the asker forager. #[derive(Debug, Clone, Default)] struct CatalogueSlot { - sid: String, tools: Vec, } @@ -66,13 +63,12 @@ impl McpBridge { /// merge can filter capability-overlapping nestler tools). pub fn set_catalogue( &self, - sid: impl Into, forager_tools: Vec, nestler_tools: Vec, provided: &[String], ) { let merged = catalogue::merge(forager_tools, nestler_tools, provided); - *self.catalogue.write() = CatalogueSlot { sid: sid.into(), tools: merged }; + *self.catalogue.write() = CatalogueSlot { tools: merged }; } /// Resolve a pending `tools/call` with the result from a @@ -123,7 +119,7 @@ mod tests { let shipped = Arc::new(PlMutex::new(Vec::::new())); let shipped_for_closure = shipped.clone(); let bridge = McpBridge::new(Arc::new(move |t| shipped_for_closure.lock().push(t))); - bridge.set_catalogue("hum-test", vec![def("humfs_read")], vec![], &["fs".into()]); + bridge.set_catalogue(vec![def("humfs_read")], vec![], &["fs".into()]); let addr = spawn_local_mcp(bridge).await.expect("bind"); let client = reqwest_get_post(); let body = json!({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}); @@ -140,7 +136,7 @@ mod tests { let shipped = Arc::new(PlMutex::new(Vec::::new())); let shipped_for_closure = shipped.clone(); let bridge = McpBridge::new(Arc::new(move |t| shipped_for_closure.lock().push(t))); - bridge.set_catalogue("hum-test", vec![def("humfs_read")], vec![], &["fs".into()]); + bridge.set_catalogue(vec![def("humfs_read")], vec![], &["fs".into()]); let addr = spawn_local_mcp(bridge.clone()).await.expect("bind"); let client = reqwest_get_post(); // Fire-and-park: post tools/call in a task; after a moment, diff --git a/hives/common/src/serve.rs b/hives/common/src/serve.rs index 34fda56e..52f92635 100644 --- a/hives/common/src/serve.rs +++ b/hives/common/src/serve.rs @@ -193,7 +193,7 @@ async fn dial_and_serve( .map(|a| a.iter().filter_map(parse_tool_def).collect()) .unwrap_or_default(); if !forager_tools.is_empty() || !nestler_tools.is_empty() { - bridge.set_catalogue(&sid, forager_tools, nestler_tools, &provided); + bridge.set_catalogue(forager_tools, nestler_tools, &provided); } let worker = worker.clone(); let write_half = write_half.clone(); diff --git a/hives/humfs/src/tools/read.rs b/hives/humfs/src/tools/read.rs index 13880f96..e352d6e1 100644 --- a/hives/humfs/src/tools/read.rs +++ b/hives/humfs/src/tools/read.rs @@ -460,31 +460,6 @@ fn study_image(path: &Path) -> ToolResult { } } -// ── code file detection ───────────────────────────────────────────────── - -fn is_code_file(path: &Path) -> bool { - let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase(); - matches!( - ext.as_str(), - "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" - | "py" | "pyi" - | "go" - | "rs" - | "java" - | "c" | "cc" | "cpp" | "cxx" | "h" | "hpp" | "hxx" - | "rb" - | "php" - | "cs" - | "kt" | "kts" - | "swift" - | "scala" - | "lua" - | "sh" | "bash" | "zsh" | "fish" - | "vue" | "svelte" - | "sql" - ) -} - // ── helpers ───────────────────────────────────────────────────────────── fn safe_line_count(p: &Path) -> usize { @@ -529,12 +504,4 @@ mod tests { assert!(!r2.is_match("test_xx.py")); } - #[test] - fn is_code_file_known_exts() { - assert!(is_code_file(Path::new("/x/y.ts"))); - assert!(is_code_file(Path::new("/x/y.rs"))); - assert!(is_code_file(Path::new("/x/y.py"))); - assert!(!is_code_file(Path::new("/x/y.md"))); - assert!(!is_code_file(Path::new("/x/y.json"))); - } } diff --git a/sim/tests/partition_and_heal.rs b/sim/tests/partition_and_heal.rs index e3ecbec8..83552975 100644 --- a/sim/tests/partition_and_heal.rs +++ b/sim/tests/partition_and_heal.rs @@ -64,12 +64,9 @@ async fn partition_then_heal_converges_wane() { // Heal — flushes the buffered link AND exchanges wane snapshots. sim.heal(a_id, b_id).await.expect("heal"); - // Poll up to 1s for both sides' wane to reach the joined max (8). - // The handshake is async (route → ensemble pump → HumdSink merge), - // so we give it a window rather than asserting immediately. let target = 8; let mut converged = false; - for _ in 0..100 { + for _ in 0..1000 { if a.waneman.get(SIGIL) == target && b.waneman.get(SIGIL) == target { converged = true; break; @@ -79,7 +76,7 @@ async fn partition_then_heal_converges_wane() { assert!( converged, - "wane should converge within 1s of heal: a={}, b={}", + "wane should converge within 10s of heal: a={}, b={}", a.waneman.get(SIGIL), b.waneman.get(SIGIL), ); From b25ddc3b57661aae303cf7e908b4712efd628fcb Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 18:13:05 +0000 Subject: [PATCH 06/18] P1 #40 + humnest supervisor + humctl + svc.sh death MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendezvous file (P1 #40 fix): - hum_paths::RuntimeInfo + HumnestRuntimeInfo (atomic write, read, remove). - thrumd::serve_with_hook fires on_bound after UnixListener::bind. humd uses it to publish runtime.json with socket + pid + version + bound_at_ms. - hum_paths::thrum_sock_resolved prefers env > runtime.json > default. Daemons keep thrum_sock; bees/CLI use _resolved. Socket-path drift is gone — clients always reach whatever humd actually bound. - hum doctor connect-tests with a real hello tone, 1s timeout. exists() check replaced. doctor surfaces humnest runtime.json too. - hum bee --list flags "⚠ crash-looping (exit N)" via new svc_last_exit reading systemctl ExecMainStatus / launchctl 'last exit code'. humnest crate — bee supervisor sibling daemon: - Reads humnest.bees[] from hum.json. - Each bee spawned via nest::lifecycle::supervise (group-kill, RAII). - Restart policy per bee: always | on-failure (max_retries, backoff) | never. - Crash-loop state surfaced through humnest-list RPC. - Control socket at humnest.sock (NDJSON): humnest-spawn|humnest-kill|humnest-list. - Sibling to humd: humd crash != humnest crash, bees stay alive. humctl crate — service-manager 0.7 wrapper: - humctl {install|start|stop|restart|status|uninstall} {humd|humnest}. - Pure Rust, ServiceLevel::User, cross-platform via service-manager crate. - scripts/svc.sh DELETED. 300 lines of bash, gone. - ./install + every hives/*/install rewritten to call humctl, drop svc.sh sourcing. Hive install now appends to hum.json humnest.bees + restarts humnest instead of generating per-bee systemd/launchd units. config gained humnest.bees[] schema. hum CLI: new 'hum nest' subcommand lists humnest bees with state + restart count. 'hum bee enter|exit|reenter' routes through humnest first for kinds in hum.json, falls back to legacy svc paths for unknown / 'all' targets. 247 tests pass. --- Cargo.lock | 131 +++++++++++++- Cargo.toml | 2 + config/src/lib.rs | 28 +++ hives/README.md | 2 +- hives/bp7/src/main.rs | 2 +- hives/claude-cli/install | 105 ++++++----- hives/claude-repl/install | 91 ++++++---- hives/common/src/forager.rs | 2 +- hives/common/src/serve.rs | 2 +- hives/grpc/src/main.rs | 2 +- hives/gsm-modem/src/main.rs | 2 +- hives/humfs/install | 85 +++++---- hives/ollama-server/src/main.rs | 2 +- hives/openai-server/install | 106 +++++------ hives/paid-oracle/install | 115 +++++++----- hives/paid-oracle/src/main.rs | 2 +- hum-paths/Cargo.toml | 2 + hum-paths/src/lib.rs | 87 ++++++++- hum.schema.json | 25 +++ hum/src/main.rs | 176 +++++++++++++++++- humctl/Cargo.toml | 14 ++ humctl/src/main.rs | 214 ++++++++++++++++++++++ humd/examples/smoke.rs | 2 +- humd/src/lib.rs | 23 ++- humnest/Cargo.toml | 33 ++++ humnest/src/control.rs | 91 ++++++++++ humnest/src/lib.rs | 33 ++++ humnest/src/log_capture.rs | 52 ++++++ humnest/src/main.rs | 23 +++ humnest/src/supervisor.rs | 187 +++++++++++++++++++ install | 283 +++++++++++------------------ recipes/opencode/install | 61 +++---- scripts/svc.sh | 306 -------------------------------- thrumd/src/lib.rs | 11 +- 34 files changed, 1546 insertions(+), 756 deletions(-) create mode 100644 humctl/Cargo.toml create mode 100644 humctl/src/main.rs create mode 100644 humnest/Cargo.toml create mode 100644 humnest/src/control.rs create mode 100644 humnest/src/lib.rs create mode 100644 humnest/src/log_capture.rs create mode 100644 humnest/src/main.rs create mode 100644 humnest/src/supervisor.rs delete mode 100755 scripts/svc.sh diff --git a/Cargo.lock b/Cargo.lock index af6e641a..d51e0ebe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1061,6 +1061,26 @@ dependencies = [ "crypto-common 0.2.1", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "discard" version = "1.0.4" @@ -1817,6 +1837,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -1881,6 +1910,10 @@ dependencies = [ [[package]] name = "hum-paths" version = "0.31.18" +dependencies = [ + "serde", + "serde_json", +] [[package]] name = "humantime" @@ -1888,6 +1921,14 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "humctl" +version = "0.31.18" +dependencies = [ + "anyhow", + "service-manager", +] + [[package]] name = "humd" version = "0.31.18" @@ -1948,6 +1989,28 @@ dependencies = [ "tree-sitter-typescript", ] +[[package]] +name = "humnest" +version = "0.31.18" +dependencies = [ + "anyhow", + "command-group", + "config", + "hum-paths", + "metrics", + "nest", + "parking_lot", + "serde", + "serde_json", + "tempfile", + "thrum-core", + "thrumd", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + [[package]] name = "hums" version = "0.31.18" @@ -2598,6 +2661,21 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4079,6 +4157,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -4268,6 +4357,19 @@ dependencies = [ "semver 1.0.28", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4277,7 +4379,7 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -4586,6 +4688,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "service-manager" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d7d62c9733631445d1b3fc7854c780088408d4b79a20dd928aaec41854ca3a" +dependencies = [ + "cfg-if", + "dirs", + "plist", + "which", + "xml-rs", +] + [[package]] name = "sha1" version = "0.6.1" @@ -5023,7 +5138,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5891,6 +6006,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 007f19bd..9114f3ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ members = [ "codegen", "drift", "humd", + "humnest", + "humctl", "hum", "mcp", "nest", diff --git a/config/src/lib.rs b/config/src/lib.rs index 2cc53037..aa411b75 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -133,6 +133,29 @@ impl Default for NestSection { } } +// ── humnest ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HumnestSection { + #[serde(default)] + pub bees: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BeeConfig { + pub kind: String, + #[serde(default)] + pub argv: Vec, + #[serde(default)] + pub env: std::collections::HashMap, + #[serde(default = "defaults::restart")] + pub restart: String, + #[serde(default = "defaults::max_retries", rename = "maxRetries")] + pub max_retries: u32, + #[serde(default = "defaults::backoff_ms", rename = "backoffMs")] + pub backoff_ms: u64, +} + // ── top-level ───────────────────────────────────────────────────────────── /// Daemon-scoped policy: `humd` knobs, `fs` grounding, and `nest` @@ -146,6 +169,8 @@ pub struct HumConfig { pub fs: FsSection, #[serde(default)] pub nest: NestSection, + #[serde(default)] + pub humnest: HumnestSection, } // ── path resolution ─────────────────────────────────────────────────────── @@ -287,6 +312,9 @@ mod defaults { pub fn default_hive() -> String { "claude-repl".into() } + pub fn restart() -> String { "always".into() } + pub fn max_retries() -> u32 { 10 } + pub fn backoff_ms() -> u64 { 1_000 } pub fn denied() -> Vec { [ "~/.ssh", diff --git a/hives/README.md b/hives/README.md index 2f6549b2..c0182bcf 100644 --- a/hives/README.md +++ b/hives/README.md @@ -107,7 +107,7 @@ In **local dev** you run `cargo run -p humd` and then launch your bee binary. Bo In the **ensemble**, a bee on one machine reaches a humd on another over the ensemble transport. The remote humd sees the same hello and routes normally. A `peers.json` with one bootstrap entry turns this on. Nothing installs on the remote humd's disk, and the `source` URL stays purely informational. -As a **managed service** you keep a bee alive across reboots. Ship a `hives//install` modeled on [`paid-oracle/install`](paid-oracle/install), which registers a service through [`scripts/svc.sh`](../scripts/svc.sh) as `hum-`. From there the CLI drives it. +As a **managed service** you keep a bee alive across reboots. Ship a `hives//install` modeled on [`paid-oracle/install`](paid-oracle/install), which appends the bee to `hum.json` `humnest.bees` and bounces humnest. humnest then supervises the child; from there the CLI drives it. ``` hum hive --list # catalogue: installer, configured, running diff --git a/hives/bp7/src/main.rs b/hives/bp7/src/main.rs index 6a239822..985f8c05 100644 --- a/hives/bp7/src/main.rs +++ b/hives/bp7/src/main.rs @@ -64,7 +64,7 @@ impl Config { node_eid: std::env::var("BP7_NODE_EID") .unwrap_or_else(|_| "dtn://hum.local/inference".into()), model: std::env::var("BP7_MODEL").unwrap_or_else(|_| "claude-sonnet-4".into()), - sock_path: hum_paths::thrum_sock().to_string_lossy().into_owned(), + sock_path: hum_paths::thrum_sock_resolved().to_string_lossy().into_owned(), }) } } diff --git a/hives/claude-cli/install b/hives/claude-cli/install index 7a5ba6e0..70897793 100755 --- a/hives/claude-cli/install +++ b/hives/claude-cli/install @@ -1,13 +1,13 @@ #!/usr/bin/env bash -# claude-cli worker-bee installer — builds the standalone binary, -# installs a user systemd / launchd unit, and starts it. Pairs with -# whatever humd is on the machine via the canonical thrum socket. humd -# never learns about claude-cli at compile time; this installer -# registers the worker at runtime via thrum bee:["worker"]. +# claude-cli worker-bee installer — builds the standalone binary and +# registers it with humnest by appending an entry to hum.json +# humnest.bees, then bouncing humnest so the new child is picked up. +# +# No per-bee systemd / launchd unit: humnest is the supervisor now. # # Usage: -# hives/claude-cli/install # build + install unit + start -# hives/claude-cli/install uninstall # stop + remove unit +# hives/claude-cli/install # build + register with humnest +# hives/claude-cli/install uninstall # remove from humnest.bees + restart # # Env: # HUM_THRUM_SOCK default $XDG_RUNTIME_DIR/hum/thrum.sock @@ -19,13 +19,12 @@ set -euo pipefail XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" HUM_CONFIG="$XDG_CONFIG_HOME/hum" +HUM_JSON="$HUM_CONFIG/hum.json" HIVE_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$HIVE_DIR/../.." && pwd)" KIND="claude-cli" -UNIT_NAME="hum-$KIND-worker" WORKER_BIN="$HOME/.local/bin/${KIND}-worker" +HUMCTL_BIN="$HOME/.local/bin/humctl" -# Canonical thrum socket — matches WIRE.md + the daemon installer. if [ -n "${XDG_RUNTIME_DIR:-}" ]; then DEFAULT_SOCK="$XDG_RUNTIME_DIR/hum/thrum.sock" else @@ -33,22 +32,13 @@ else fi THRUM_SOCK="${HUM_THRUM_SOCK:-$DEFAULT_SOCK}" CLAUDE_BIN="${CLAUDE_CLI_PATH:-$(command -v claude || echo claude)}" +CLAUDE_MODELS="${CLAUDE_MODELS:-claude-opus-4-7,claude-sonnet-4-6,claude-haiku-4-5,claude-sonnet-4-5,claude-haiku-4-5-20251001}" log() { printf '\033[1m[%s-worker]\033[0m %s\n' "$KIND" "$*"; } warn() { printf '\033[1;33m[%s-worker]\033[0m %s\n' "$KIND" "$*" >&2; } fail() { printf '\033[1;31m[%s-worker]\033[0m %s\n' "$KIND" "$*" >&2; exit 1; } -# Source the cross-platform service helper. -SVC="$REPO_ROOT/scripts/svc.sh" -[ -f "$SVC" ] || fail "scripts/svc.sh missing" -# shellcheck source=/dev/null -. "$SVC" - build() { - # Rebuild whenever cargo is available so an install/update always - # lands current code (--force is idempotent). Fall back to a - # pre-built binary only when there is no toolchain (dev/deploy or a - # release artifact supplied it). if command -v cargo >/dev/null 2>&1; then log "building $KIND-worker (cargo install --path $HIVE_DIR)" cargo install --quiet --locked --path "$HIVE_DIR" --root "$HOME/.local" --force --bin "${KIND}-worker" @@ -61,36 +51,65 @@ build() { fi } -install_unit() { - case "$SVC_OS" in - Linux|Darwin) ;; - *) warn "service install unsupported on $SVC_OS — run '$WORKER_BIN' manually"; return 0 ;; - esac - svc_install "$UNIT_NAME" "$WORKER_BIN" \ - --env "HOME=$HOME" \ - --env "HUM_LOG_LEVEL=trace,penny=error" \ - --env "XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --env "HUM_THRUM_SOCK=$THRUM_SOCK" \ - --env "CLAUDE_CLI_PATH=$CLAUDE_BIN" \ - --env "CLAUDE_MODELS=${CLAUDE_MODELS:-claude-opus-4-7,claude-sonnet-4-6,claude-haiku-4-5,claude-sonnet-4-5,claude-haiku-4-5-20251001}" \ - --env "PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" - svc_restart "$UNIT_NAME" 2>/dev/null || svc_start "$UNIT_NAME" 2>/dev/null || true - sleep 1 - if svc_is_active "$UNIT_NAME"; then - log "$UNIT_NAME running ✓" - log " models advertised: ${CLAUDE_MODELS:-default}" - else - warn "$UNIT_NAME did not start cleanly" - fi +# Append (or replace) the claude-cli bee in hum.json humnest.bees. +register_with_humnest() { + command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" + [ -s "$HUM_JSON" ] || fail "$HUM_JSON missing — run the top-level ./install first" + + local bee_json + bee_json=$(jq -n \ + --arg kind "$KIND" \ + --arg program "$WORKER_BIN" \ + --arg home "$HOME" \ + --arg log_level "trace,penny=error" \ + --arg runtime "${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ + --arg sock "$THRUM_SOCK" \ + --arg claude_bin "$CLAUDE_BIN" \ + --arg claude_models "$CLAUDE_MODELS" \ + --arg path "$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" \ + '{kind: $kind, argv: [$program], env: { + HOME: $home, + HUM_LOG_LEVEL: $log_level, + XDG_RUNTIME_DIR: $runtime, + HUM_THRUM_SOCK: $sock, + CLAUDE_CLI_PATH: $claude_bin, + CLAUDE_MODELS: $claude_models, + PATH: $path + }}') + + local tmp; tmp=$(mktemp) + jq --argjson bee "$bee_json" ' + .humnest //= {bees: []} + | .humnest.bees //= [] + | .humnest.bees |= (map(select(.kind != $bee.kind)) + [$bee]) + ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" + log "registered $KIND in humnest.bees" +} + +bounce_humnest() { + [ -x "$HUMCTL_BIN" ] || { warn "humctl not at $HUMCTL_BIN — start humnest manually to pick up new bee"; return 0; } + "$HUMCTL_BIN" restart humnest 2>/dev/null \ + || "$HUMCTL_BIN" start humnest 2>/dev/null \ + || warn "could not restart humnest via humctl" + log "humnest bounced — $KIND should spawn on its next tick" + log " models advertised: $CLAUDE_MODELS" } uninstall() { - svc_uninstall "$UNIT_NAME" 2>/dev/null || true + command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" + if [ -s "$HUM_JSON" ]; then + local tmp; tmp=$(mktemp) + jq --arg kind "$KIND" ' + .humnest.bees |= (. // [] | map(select(.kind != $kind))) + ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" + log "removed $KIND from humnest.bees" + fi + [ -x "$HUMCTL_BIN" ] && "$HUMCTL_BIN" restart humnest 2>/dev/null || true log "uninstalled." } case "${1:-install}" in - install|"") build && install_unit ;; + install|"") build && register_with_humnest && bounce_humnest ;; uninstall) uninstall ;; *) fail "unknown command: $1 (try: install, uninstall)" ;; esac diff --git a/hives/claude-repl/install b/hives/claude-repl/install index bbd5505a..fbcf1016 100755 --- a/hives/claude-repl/install +++ b/hives/claude-repl/install @@ -1,15 +1,15 @@ #!/usr/bin/env bash # claude-repl worker-bee installer — same shape as claude-cli's. Builds -# the standalone binary, registers a user systemd / launchd unit, -# starts. humd never learns about claude-repl at compile time; the -# worker registers at runtime via thrum bee:["worker"]. +# the standalone binary and registers it with humnest via hum.json +# humnest.bees, then bounces humnest. No per-bee unit. set -euo pipefail +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +HUM_JSON="$XDG_CONFIG_HOME/hum/hum.json" HIVE_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$HIVE_DIR/../.." && pwd)" KIND="claude-repl" -UNIT_NAME="hum-$KIND-worker" WORKER_BIN="$HOME/.local/bin/${KIND}-worker" +HUMCTL_BIN="$HOME/.local/bin/humctl" if [ -n "${XDG_RUNTIME_DIR:-}" ]; then DEFAULT_SOCK="$XDG_RUNTIME_DIR/hum/thrum.sock" @@ -18,20 +18,13 @@ else fi THRUM_SOCK="${HUM_THRUM_SOCK:-$DEFAULT_SOCK}" CLAUDE_BIN="${CLAUDE_CLI_PATH:-$(command -v claude || echo claude)}" -XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +CLAUDE_MODELS="${CLAUDE_MODELS:-claude-opus-4-7,claude-sonnet-4-6,claude-haiku-4-5}" log() { printf '\033[1m[%s-worker]\033[0m %s\n' "$KIND" "$*"; } warn() { printf '\033[1;33m[%s-worker]\033[0m %s\n' "$KIND" "$*" >&2; } fail() { printf '\033[1;31m[%s-worker]\033[0m %s\n' "$KIND" "$*" >&2; exit 1; } -SVC="$REPO_ROOT/scripts/svc.sh" -[ -f "$SVC" ] || fail "scripts/svc.sh missing" -# shellcheck source=/dev/null -. "$SVC" - build() { - # Rebuild when cargo is present (always current; --force idempotent); - # use a pre-built binary only when there is no toolchain. if command -v cargo >/dev/null 2>&1; then log "building $KIND-worker (cargo install --path $HIVE_DIR)" cargo install --quiet --locked --path "$HIVE_DIR" --root "$HOME/.local" --force --bin "${KIND}-worker" @@ -44,35 +37,63 @@ build() { fi } -install_unit() { - case "$SVC_OS" in - Linux|Darwin) ;; - *) warn "service install unsupported on $SVC_OS — run '$WORKER_BIN' manually"; return 0 ;; - esac - svc_install "$UNIT_NAME" "$WORKER_BIN" \ - --env "HOME=$HOME" \ - --env "HUM_LOG_LEVEL=trace,penny=error" \ - --env "XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --env "HUM_THRUM_SOCK=$THRUM_SOCK" \ - --env "CLAUDE_CLI_PATH=$CLAUDE_BIN" \ - --env "CLAUDE_MODELS=${CLAUDE_MODELS:-claude-opus-4-7,claude-sonnet-4-6,claude-haiku-4-5}" \ - --env "PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" - svc_restart "$UNIT_NAME" 2>/dev/null || svc_start "$UNIT_NAME" 2>/dev/null || true - sleep 1 - if svc_is_active "$UNIT_NAME"; then - log "$UNIT_NAME running ✓" - else - warn "$UNIT_NAME did not start cleanly" - fi +register_with_humnest() { + command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" + [ -s "$HUM_JSON" ] || fail "$HUM_JSON missing — run the top-level ./install first" + + local bee_json + bee_json=$(jq -n \ + --arg kind "$KIND" \ + --arg program "$WORKER_BIN" \ + --arg home "$HOME" \ + --arg log_level "trace,penny=error" \ + --arg runtime "${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ + --arg sock "$THRUM_SOCK" \ + --arg claude_bin "$CLAUDE_BIN" \ + --arg claude_models "$CLAUDE_MODELS" \ + --arg path "$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" \ + '{kind: $kind, argv: [$program], env: { + HOME: $home, + HUM_LOG_LEVEL: $log_level, + XDG_RUNTIME_DIR: $runtime, + HUM_THRUM_SOCK: $sock, + CLAUDE_CLI_PATH: $claude_bin, + CLAUDE_MODELS: $claude_models, + PATH: $path + }}') + + local tmp; tmp=$(mktemp) + jq --argjson bee "$bee_json" ' + .humnest //= {bees: []} + | .humnest.bees //= [] + | .humnest.bees |= (map(select(.kind != $bee.kind)) + [$bee]) + ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" + log "registered $KIND in humnest.bees" +} + +bounce_humnest() { + [ -x "$HUMCTL_BIN" ] || { warn "humctl not at $HUMCTL_BIN — start humnest manually"; return 0; } + "$HUMCTL_BIN" restart humnest 2>/dev/null \ + || "$HUMCTL_BIN" start humnest 2>/dev/null \ + || warn "could not restart humnest via humctl" + log "humnest bounced — $KIND should spawn on its next tick" } uninstall() { - svc_uninstall "$UNIT_NAME" 2>/dev/null || true + command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" + if [ -s "$HUM_JSON" ]; then + local tmp; tmp=$(mktemp) + jq --arg kind "$KIND" ' + .humnest.bees |= (. // [] | map(select(.kind != $kind))) + ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" + log "removed $KIND from humnest.bees" + fi + [ -x "$HUMCTL_BIN" ] && "$HUMCTL_BIN" restart humnest 2>/dev/null || true log "uninstalled." } case "${1:-install}" in - install|"") build && install_unit ;; + install|"") build && register_with_humnest && bounce_humnest ;; uninstall) uninstall ;; *) fail "unknown command: $1 (try: install, uninstall)" ;; esac diff --git a/hives/common/src/forager.rs b/hives/common/src/forager.rs index 40c20615..7d9a7bd7 100644 --- a/hives/common/src/forager.rs +++ b/hives/common/src/forager.rs @@ -114,7 +114,7 @@ impl Default for ForagerAdvert { } fn default_socket_path() -> PathBuf { - hum_paths::thrum_sock() + hum_paths::thrum_sock_resolved() } /// Run the forager service loop. Blocks until shutdown; reconnects diff --git a/hives/common/src/serve.rs b/hives/common/src/serve.rs index 52f92635..cfb7b108 100644 --- a/hives/common/src/serve.rs +++ b/hives/common/src/serve.rs @@ -43,7 +43,7 @@ use crate::identity::load_or_mint_bee_key; use crate::mcp_bridge::{spawn_local_mcp, McpBridge}; fn default_socket_path() -> PathBuf { - hum_paths::thrum_sock() + hum_paths::thrum_sock_resolved() } /// What the host advertises on hello. Drives both routing (humd maps diff --git a/hives/grpc/src/main.rs b/hives/grpc/src/main.rs index b25db258..c2d3f9ee 100644 --- a/hives/grpc/src/main.rs +++ b/hives/grpc/src/main.rs @@ -31,7 +31,7 @@ const HIVE_NAME: &str = "grpc"; const NESTLING_VERSION: &str = env!("CARGO_PKG_VERSION"); fn humd_sock_path() -> String { - hum_paths::thrum_sock().to_string_lossy().into_owned() + hum_paths::thrum_sock_resolved().to_string_lossy().into_owned() } /// One bidi stream's bridge: open a thrum connection, pump tones both ways. diff --git a/hives/gsm-modem/src/main.rs b/hives/gsm-modem/src/main.rs index dcbb466d..2aa339f3 100644 --- a/hives/gsm-modem/src/main.rs +++ b/hives/gsm-modem/src/main.rs @@ -68,7 +68,7 @@ impl Config { .ok() .and_then(|s| s.parse().ok()) .unwrap_or(1500), - sock_path: hum_paths::thrum_sock().to_string_lossy().into_owned(), + sock_path: hum_paths::thrum_sock_resolved().to_string_lossy().into_owned(), } } } diff --git a/hives/humfs/install b/hives/humfs/install index 927f3268..d6ac5da4 100755 --- a/hives/humfs/install +++ b/hives/humfs/install @@ -1,22 +1,23 @@ #!/usr/bin/env bash # humfs forager-bee installer — builds the standalone humfs-forager -# binary, installs a user systemd / launchd unit, and starts it. The -# forager registers with humd via thrum bee:["forager"] and advertises -# the humfs_* tool set humd routes other foragers' tool-calls through. +# binary and registers it with humnest via hum.json humnest.bees. +# humnest supervises the child; the forager registers with humd via +# thrum bee:["forager"] once it's running. # # Usage: -# hives/humfs/install # build + install unit + start -# hives/humfs/install uninstall # stop + remove unit +# hives/humfs/install # build + register +# hives/humfs/install uninstall # remove from humnest.bees # # Env: # HUM_THRUM_SOCK default $XDG_RUNTIME_DIR/hum/thrum.sock set -euo pipefail +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +HUM_JSON="$XDG_CONFIG_HOME/hum/hum.json" HIVE_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$HIVE_DIR/../.." && pwd)" KIND="humfs" -UNIT_NAME="hum-$KIND-forager" FORAGER_BIN="$HOME/.local/bin/${KIND}-forager" +HUMCTL_BIN="$HOME/.local/bin/humctl" if [ -n "${XDG_RUNTIME_DIR:-}" ]; then DEFAULT_SOCK="$XDG_RUNTIME_DIR/hum/thrum.sock" @@ -29,14 +30,7 @@ log() { printf '\033[1m[%s-forager]\033[0m %s\n' "$KIND" "$*"; } warn() { printf '\033[1;33m[%s-forager]\033[0m %s\n' "$KIND" "$*" >&2; } fail() { printf '\033[1;31m[%s-forager]\033[0m %s\n' "$KIND" "$*" >&2; exit 1; } -SVC="$REPO_ROOT/scripts/svc.sh" -[ -f "$SVC" ] || fail "scripts/svc.sh missing" -# shellcheck source=/dev/null -. "$SVC" - build() { - # Rebuild when cargo is present (always current; --force idempotent); - # use a pre-built binary only when there is no toolchain. if command -v cargo >/dev/null 2>&1; then log "building $KIND-forager (cargo install --path $HIVE_DIR)" cargo install --quiet --locked --path "$HIVE_DIR" --root "$HOME/.local" --force --bin "${KIND}-forager" @@ -49,32 +43,57 @@ build() { fi } -install_unit() { - case "$SVC_OS" in - Linux|Darwin) ;; - *) warn "service install unsupported on $SVC_OS — run '$FORAGER_BIN' manually"; return 0 ;; - esac - svc_install "$UNIT_NAME" "$FORAGER_BIN" \ - --env "HUM_LOG_LEVEL=trace,penny=error" \ - --env "XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --env "HUM_THRUM_SOCK=$THRUM_SOCK" \ - --env "PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" - svc_restart "$UNIT_NAME" 2>/dev/null || svc_start "$UNIT_NAME" 2>/dev/null || true - sleep 1 - if svc_is_active "$UNIT_NAME"; then - log "$UNIT_NAME running ✓" - else - warn "$UNIT_NAME did not start cleanly" - fi +register_with_humnest() { + command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" + [ -s "$HUM_JSON" ] || fail "$HUM_JSON missing — run the top-level ./install first" + + local bee_json + bee_json=$(jq -n \ + --arg kind "$KIND" \ + --arg program "$FORAGER_BIN" \ + --arg log_level "trace,penny=error" \ + --arg runtime "${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ + --arg sock "$THRUM_SOCK" \ + --arg path "$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" \ + '{kind: $kind, argv: [$program], env: { + HUM_LOG_LEVEL: $log_level, + XDG_RUNTIME_DIR: $runtime, + HUM_THRUM_SOCK: $sock, + PATH: $path + }}') + + local tmp; tmp=$(mktemp) + jq --argjson bee "$bee_json" ' + .humnest //= {bees: []} + | .humnest.bees //= [] + | .humnest.bees |= (map(select(.kind != $bee.kind)) + [$bee]) + ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" + log "registered $KIND in humnest.bees" +} + +bounce_humnest() { + [ -x "$HUMCTL_BIN" ] || { warn "humctl not at $HUMCTL_BIN — start humnest manually"; return 0; } + "$HUMCTL_BIN" restart humnest 2>/dev/null \ + || "$HUMCTL_BIN" start humnest 2>/dev/null \ + || warn "could not restart humnest via humctl" + log "humnest bounced — $KIND should spawn on its next tick" } uninstall() { - svc_uninstall "$UNIT_NAME" 2>/dev/null || true + command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" + if [ -s "$HUM_JSON" ]; then + local tmp; tmp=$(mktemp) + jq --arg kind "$KIND" ' + .humnest.bees |= (. // [] | map(select(.kind != $kind))) + ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" + log "removed $KIND from humnest.bees" + fi + [ -x "$HUMCTL_BIN" ] && "$HUMCTL_BIN" restart humnest 2>/dev/null || true log "uninstalled." } case "${1:-install}" in - install|"") build && install_unit ;; + install|"") build && register_with_humnest && bounce_humnest ;; uninstall) uninstall ;; *) fail "unknown command: $1 (try: install, uninstall)" ;; esac diff --git a/hives/ollama-server/src/main.rs b/hives/ollama-server/src/main.rs index 831c9780..87e90a1f 100644 --- a/hives/ollama-server/src/main.rs +++ b/hives/ollama-server/src/main.rs @@ -95,7 +95,7 @@ impl Config { }), }; Self { - sock_path: hum_paths::thrum_sock().to_string_lossy().into_owned(), + sock_path: hum_paths::thrum_sock_resolved().to_string_lossy().into_owned(), listen: format!("{host}:{port}"), models, bind: None, diff --git a/hives/openai-server/install b/hives/openai-server/install index bbbdc5e2..c3003440 100755 --- a/hives/openai-server/install +++ b/hives/openai-server/install @@ -1,14 +1,14 @@ #!/usr/bin/env bash # openai-server bee installer. # -# Stands alone. Builds the TS bundle, seeds the per-kind config at -# ~/.config/hum/hives/openai-server.json, installs a user systemd -# unit. Pairs with whatever humd is on the machine via the thrum -# socket — no assumption about how humd was installed. +# Builds the TS bundle, seeds the per-kind config at +# ~/.config/hum/hives/openai-server.json, and registers the bee with +# humnest via hum.json humnest.bees. humnest supervises it; the bee +# pairs with humd via the thrum socket. # # Usage: -# hives/openai-server/install # build + install unit + start -# hives/openai-server/install uninstall # stop + remove unit + binary +# hives/openai-server/install # build + register + bounce humnest +# hives/openai-server/install uninstall # remove from humnest.bees # # Env: # OPENAI_SERVER_PORT default 14620 @@ -18,10 +18,10 @@ set -euo pipefail XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" HUM_CONFIG="$XDG_CONFIG_HOME/hum" +HUM_JSON="$HUM_CONFIG/hum.json" PORT="${OPENAI_SERVER_PORT:-14620}" HOST="${OPENAI_SERVER_HOST:-127.0.0.1}" -# Canonical thrum socket — matches WIRE.md + hum installer. if [ -n "${XDG_RUNTIME_DIR:-}" ]; then DEFAULT_SOCK="$XDG_RUNTIME_DIR/hum/thrum.sock" else @@ -31,10 +31,8 @@ THRUM_SOCK="${HUM_THRUM_SOCK:-$DEFAULT_SOCK}" NESTDIR="$(cd "$(dirname "$0")" && pwd)" KIND="openai-server" -UNIT_NAME="hum-$KIND" -UNIT="$XDG_CONFIG_HOME/systemd/user/$UNIT_NAME.service" CFG="$HUM_CONFIG/hives/$KIND.json" -OS="$(uname -s)" +HUMCTL_BIN="$HOME/.local/bin/humctl" log() { printf '\033[1m[%s]\033[0m %s\n' "$KIND" "$*"; } warn() { printf '\033[1;33m[%s]\033[0m %s\n' "$KIND" "$*" >&2; } @@ -45,7 +43,7 @@ CMD="${1:-install}" build() { local DIST="$NESTDIR/dist/index.js" # Pre-built dist (e.g. produced upstream by `./dev/deploy`) is honored - # so machines without pnpm can still install the systemd unit. + # so machines without pnpm can still install. if [ -s "$DIST" ] && ! command -v pnpm >/dev/null 2>&1; then log "pnpm absent; using pre-built $DIST" return 0 @@ -60,7 +58,7 @@ build() { # Locate node binary. Prefers $PATH; falls back to fnm's default # installation so people whose login shell doesn't source fnm still -# get a working systemd unit. +# get a working install. find_node() { if command -v node >/dev/null 2>&1; then command -v node @@ -94,54 +92,57 @@ JSON log "wrote default config: $CFG" } -# Resolve svc.sh — try sibling repo first (running from a clone), -# then the rsynced source tree (paradigm 2 installer flow). -_load_svc() { - for cand in \ - "$NESTDIR/../../scripts/svc.sh" \ - "$HOME/.local/share/hum/src/scripts/svc.sh" \ - ; do - if [ -f "$cand" ]; then - # shellcheck source=/dev/null - . "$cand" - return 0 - fi - done - return 1 -} - -install_unit() { - if ! _load_svc; then - warn "scripts/svc.sh not found — run 'node $NESTDIR/dist/index.js' manually" - return 0 - fi - case "$SVC_OS" in - Linux|Darwin) ;; - *) warn "service install unsupported on $SVC_OS. Run 'node $NESTDIR/dist/index.js' manually."; return 0 ;; - esac +register_with_humnest() { + command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" + [ -s "$HUM_JSON" ] || fail "$HUM_JSON missing — run the top-level ./install first" local NODE_BIN NODE_BIN="$(find_node || true)" [ -n "$NODE_BIN" ] || fail "node missing. Install node ≥ 18." log "node: $NODE_BIN" - svc_install "$UNIT_NAME" "$NODE_BIN $NESTDIR/dist/index.js" \ - --env "XDG_CONFIG_HOME=$XDG_CONFIG_HOME" \ - --env "XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --env "HUM_THRUM_SOCK=$THRUM_SOCK" - svc_restart "$UNIT_NAME" 2>/dev/null || svc_start "$UNIT_NAME" 2>/dev/null || true - sleep 1 - if svc_is_active "$UNIT_NAME"; then - log "$UNIT_NAME running on :$PORT ✓" - log " test: curl http://$HOST:$PORT/v1/models" - else - warn "$UNIT_NAME did not start cleanly" - fi + local DIST="$NESTDIR/dist/index.js" + local bee_json + bee_json=$(jq -n \ + --arg kind "$KIND" \ + --arg node "$NODE_BIN" \ + --arg script "$DIST" \ + --arg config_home "$XDG_CONFIG_HOME" \ + --arg runtime "${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ + --arg sock "$THRUM_SOCK" \ + '{kind: $kind, argv: [$node, $script], env: { + XDG_CONFIG_HOME: $config_home, + XDG_RUNTIME_DIR: $runtime, + HUM_THRUM_SOCK: $sock + }}') + + local tmp; tmp=$(mktemp) + jq --argjson bee "$bee_json" ' + .humnest //= {bees: []} + | .humnest.bees //= [] + | .humnest.bees |= (map(select(.kind != $bee.kind)) + [$bee]) + ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" + log "registered $KIND in humnest.bees on :$PORT" + log " test: curl http://$HOST:$PORT/v1/models" +} + +bounce_humnest() { + [ -x "$HUMCTL_BIN" ] || { warn "humctl not at $HUMCTL_BIN — start humnest manually"; return 0; } + "$HUMCTL_BIN" restart humnest 2>/dev/null \ + || "$HUMCTL_BIN" start humnest 2>/dev/null \ + || warn "could not restart humnest via humctl" + log "humnest bounced — $KIND should spawn on its next tick" } uninstall() { - if _load_svc; then - svc_uninstall "$UNIT_NAME" 2>/dev/null || true + command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" + if [ -s "$HUM_JSON" ]; then + local tmp; tmp=$(mktemp) + jq --arg kind "$KIND" ' + .humnest.bees |= (. // [] | map(select(.kind != $kind))) + ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" + log "removed $KIND from humnest.bees" fi + [ -x "$HUMCTL_BIN" ] && "$HUMCTL_BIN" restart humnest 2>/dev/null || true log "uninstalled. Config at $CFG preserved." } @@ -149,7 +150,8 @@ case "$CMD" in install|"") build seed_config - install_unit + register_with_humnest + bounce_humnest ;; uninstall) uninstall ;; *) fail "unknown command: $CMD (try: install, uninstall)" ;; diff --git a/hives/paid-oracle/install b/hives/paid-oracle/install index 3c765170..0b8bfcc9 100755 --- a/hives/paid-oracle/install +++ b/hives/paid-oracle/install @@ -1,14 +1,14 @@ #!/usr/bin/env bash -# paid-oracle forager-bee installer. Builds the binary, writes a -# user systemd / launchd unit, starts it. The bee registers with humd -# via bee:["forager"], hive:"paid-oracle", provides:["x402:quote"], -# tools:[{name:"quote", ...}]. Tool calls land on it by name; first -# call returns chi:"error" 402 with terms, asker pays USDC on the -# configured chain and resubmits with paymentProof. +# paid-oracle forager-bee installer. Builds the binary and registers it +# with humnest via hum.json humnest.bees. humnest supervises it; the +# bee registers with humd via bee:["forager"], hive:"paid-oracle", +# provides:["x402:quote"]. First call returns chi:"error" 402 with +# terms, asker pays USDC on the configured chain and resubmits with +# paymentProof. # # Usage: -# hives/paid-oracle/install # build + install unit + start -# hives/paid-oracle/install uninstall # stop + remove unit +# hives/paid-oracle/install # build + register +# hives/paid-oracle/install uninstall # remove from humnest.bees # # Required env (or read from ~/.config/hum/paid-oracle/keys.json # receiver.address if present): @@ -22,11 +22,12 @@ # PAID_ORACLE_PAY_KIND erc20 | native set -euo pipefail +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +HUM_JSON="$XDG_CONFIG_HOME/hum/hum.json" HIVE_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "$HIVE_DIR/../.." && pwd)" KIND="paid-oracle" -UNIT_NAME="hum-$KIND" BIN="$HOME/.local/bin/${KIND}" +HUMCTL_BIN="$HOME/.local/bin/humctl" if [ -n "${XDG_RUNTIME_DIR:-}" ]; then DEFAULT_SOCK="$XDG_RUNTIME_DIR/hum/thrum.sock" @@ -35,10 +36,11 @@ else fi THRUM_SOCK="${HUM_THRUM_SOCK:-$DEFAULT_SOCK}" -KEYS_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/hum/paid-oracle/keys.json" +KEYS_PATH="$XDG_CONFIG_HOME/hum/paid-oracle/keys.json" # Derive PAY_TO from generated keys if env not set. jq if available; -# else a tr-based fallback so installs don't depend on jq. +# else a tr-based fallback so installs don't depend on jq for derivation +# (jq is still required for the humnest registration step below). if [ -z "${PAID_ORACLE_PAY_TO:-}" ] && [ -f "$KEYS_PATH" ]; then if command -v jq >/dev/null 2>&1; then PAID_ORACLE_PAY_TO=$(jq -r '.receiver.address // empty' "$KEYS_PATH" || true) @@ -47,7 +49,6 @@ if [ -z "${PAID_ORACLE_PAY_TO:-}" ] && [ -f "$KEYS_PATH" ]; then fi fi -# Base Sepolia testnet defaults — overridden if env is set. : "${PAID_ORACLE_CHAIN:=base-sepolia}" : "${PAID_ORACLE_RPC:=https://sepolia.base.org}" : "${PAID_ORACLE_USDC:=0x036CbD53842c5426634e7929541eC2318f3dCF7e}" @@ -58,14 +59,7 @@ log() { printf '\033[1m[%s]\033[0m %s\n' "$KIND" "$*"; } warn() { printf '\033[1;33m[%s]\033[0m %s\n' "$KIND" "$*" >&2; } fail() { printf '\033[1;31m[%s]\033[0m %s\n' "$KIND" "$*" >&2; exit 1; } -SVC="$REPO_ROOT/scripts/svc.sh" -[ -f "$SVC" ] || fail "scripts/svc.sh missing" -# shellcheck source=/dev/null -. "$SVC" - build() { - # Rebuild when cargo is present (always current; --force idempotent); - # use a pre-built binary only when there is no toolchain. if command -v cargo >/dev/null 2>&1; then log "building $KIND (cargo install --path $HIVE_DIR)" cargo install --quiet --locked --path "$HIVE_DIR" --root "$HOME/.local" --force --bin "$KIND" @@ -78,42 +72,73 @@ build() { fi } -install_unit() { - case "$SVC_OS" in - Linux|Darwin) ;; - *) warn "service install unsupported on $SVC_OS — run '$BIN' manually"; return 0 ;; - esac +register_with_humnest() { + command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" + [ -s "$HUM_JSON" ] || fail "$HUM_JSON missing — run the top-level ./install first" if [ -z "${PAID_ORACLE_PAY_TO:-}" ]; then fail "PAID_ORACLE_PAY_TO not set and no receiver.address in $KEYS_PATH; run hives/paid-oracle/buyer/keygen.ts or export PAID_ORACLE_PAY_TO." fi log "chain=$PAID_ORACLE_CHAIN pay_to=$PAID_ORACLE_PAY_TO usdc=$PAID_ORACLE_USDC" - svc_install "$UNIT_NAME" "$BIN" \ - --env "HUM_LOG_LEVEL=trace,penny=error" \ - --env "XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --env "HUM_THRUM_SOCK=$THRUM_SOCK" \ - --env "PAID_ORACLE_PAY_TO=$PAID_ORACLE_PAY_TO" \ - --env "PAID_ORACLE_CHAIN=$PAID_ORACLE_CHAIN" \ - --env "PAID_ORACLE_RPC=$PAID_ORACLE_RPC" \ - --env "PAID_ORACLE_USDC=$PAID_ORACLE_USDC" \ - --env "PAID_ORACLE_DECIMALS=$PAID_ORACLE_DECIMALS" \ - --env "PAID_ORACLE_PAY_KIND=$PAID_ORACLE_PAY_KIND" \ - --env "PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" - svc_restart "$UNIT_NAME" 2>/dev/null || svc_start "$UNIT_NAME" 2>/dev/null || true - sleep 1 - if svc_is_active "$UNIT_NAME"; then - log "$UNIT_NAME running ✓" - else - warn "$UNIT_NAME did not start cleanly" - fi + + local bee_json + bee_json=$(jq -n \ + --arg kind "$KIND" \ + --arg program "$BIN" \ + --arg log_level "trace,penny=error" \ + --arg runtime "${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ + --arg sock "$THRUM_SOCK" \ + --arg pay_to "$PAID_ORACLE_PAY_TO" \ + --arg chain "$PAID_ORACLE_CHAIN" \ + --arg rpc "$PAID_ORACLE_RPC" \ + --arg usdc "$PAID_ORACLE_USDC" \ + --arg decimals "$PAID_ORACLE_DECIMALS" \ + --arg pay_kind "$PAID_ORACLE_PAY_KIND" \ + --arg path "$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" \ + '{kind: $kind, argv: [$program], env: { + HUM_LOG_LEVEL: $log_level, + XDG_RUNTIME_DIR: $runtime, + HUM_THRUM_SOCK: $sock, + PAID_ORACLE_PAY_TO: $pay_to, + PAID_ORACLE_CHAIN: $chain, + PAID_ORACLE_RPC: $rpc, + PAID_ORACLE_USDC: $usdc, + PAID_ORACLE_DECIMALS: $decimals, + PAID_ORACLE_PAY_KIND: $pay_kind, + PATH: $path + }}') + + local tmp; tmp=$(mktemp) + jq --argjson bee "$bee_json" ' + .humnest //= {bees: []} + | .humnest.bees //= [] + | .humnest.bees |= (map(select(.kind != $bee.kind)) + [$bee]) + ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" + log "registered $KIND in humnest.bees" +} + +bounce_humnest() { + [ -x "$HUMCTL_BIN" ] || { warn "humctl not at $HUMCTL_BIN — start humnest manually"; return 0; } + "$HUMCTL_BIN" restart humnest 2>/dev/null \ + || "$HUMCTL_BIN" start humnest 2>/dev/null \ + || warn "could not restart humnest via humctl" + log "humnest bounced — $KIND should spawn on its next tick" } uninstall() { - svc_uninstall "$UNIT_NAME" 2>/dev/null || true + command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" + if [ -s "$HUM_JSON" ]; then + local tmp; tmp=$(mktemp) + jq --arg kind "$KIND" ' + .humnest.bees |= (. // [] | map(select(.kind != $kind))) + ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" + log "removed $KIND from humnest.bees" + fi + [ -x "$HUMCTL_BIN" ] && "$HUMCTL_BIN" restart humnest 2>/dev/null || true log "uninstalled." } case "${1:-install}" in - install|"") build && install_unit ;; + install|"") build && register_with_humnest && bounce_humnest ;; uninstall) uninstall ;; *) fail "unknown command: $1 (try: install, uninstall)" ;; esac diff --git a/hives/paid-oracle/src/main.rs b/hives/paid-oracle/src/main.rs index d1622947..8dc02569 100644 --- a/hives/paid-oracle/src/main.rs +++ b/hives/paid-oracle/src/main.rs @@ -97,7 +97,7 @@ impl Config { PayKind::Erc20 => 6, }); Ok(Self { - sock_path: hum_paths::thrum_sock().to_string_lossy().into_owned(), + sock_path: hum_paths::thrum_sock_resolved().to_string_lossy().into_owned(), pay_to: std::env::var("PAID_ORACLE_PAY_TO") .context("PAID_ORACLE_PAY_TO (your EVM address) is required")?, rpc_url: std::env::var("PAID_ORACLE_RPC") diff --git a/hum-paths/Cargo.toml b/hum-paths/Cargo.toml index 389c2839..28fe3cac 100644 --- a/hum-paths/Cargo.toml +++ b/hum-paths/Cargo.toml @@ -6,3 +6,5 @@ license.workspace = true description = "Single source of truth for every on-disk path hum writes or reads." [dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index b917a194..951e0d28 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -8,6 +8,8 @@ use std::path::PathBuf; +use serde::{Deserialize, Serialize}; + /// Set unset XDG env vars to HOME-relative defaults. /// /// Must be called once at startup in humd, hum CLI, and every hive worker. @@ -58,14 +60,25 @@ pub fn runtime_dir() -> PathBuf { // ── Named files ────────────────────────────────────────────────────────────── -/// Thrum socket: `$XDG_STATE_HOME/hum/thrum.sock`. -/// Respects `HUM_THRUM_SOCK` and the legacy `HUM_SOCKET` override. +/// Default thrum socket path. The path humd would BIND if nothing +/// overrides it. Use this from the daemon; clients should call +/// [`thrum_sock_resolved`] instead so they honor whatever path humd +/// actually published in `runtime.json`. pub fn thrum_sock() -> PathBuf { if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { return PathBuf::from(p); } if let Some(p) = std::env::var_os("HUM_SOCKET") { return PathBuf::from(p); } state_dir().join("thrum.sock") } +/// What clients (bees, CLI) should connect to. Honors humd's +/// rendezvous file first, then env overrides, then the default. +pub fn thrum_sock_resolved() -> PathBuf { + if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { return PathBuf::from(p); } + if let Some(p) = std::env::var_os("HUM_SOCKET") { return PathBuf::from(p); } + if let Some(rt) = RuntimeInfo::read() { return rt.socket; } + state_dir().join("thrum.sock") +} + /// humd HTTP control socket. pub fn http_sock() -> PathBuf { runtime_dir().join("hum.sock.http") } @@ -89,6 +102,74 @@ pub fn bees_snapshot() -> PathBuf { state_dir().join("bees.json") } /// Rendezvous file: running daemon publishes its socket path, pid, and version here. pub fn runtime_info() -> PathBuf { state_dir().join("runtime.json") } +pub fn humnest_sock() -> PathBuf { state_dir().join("humnest.sock") } +pub fn humnest_runtime() -> PathBuf { state_dir().join("humnest_runtime.json") } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeInfo { + pub socket: PathBuf, + pub pid: u32, + pub version: String, + pub thrum_version: String, + pub bound_at_ms: u64, + #[serde(default)] + pub ensemble_addrs: Vec, +} + +impl RuntimeInfo { + pub fn read() -> Option { + let raw = std::fs::read_to_string(runtime_info()).ok()?; + serde_json::from_str(&raw).ok() + } + + pub fn write(&self) -> std::io::Result<()> { + let path = runtime_info(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + let body = serde_json::to_string_pretty(self) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(&tmp, body)?; + std::fs::rename(tmp, path) + } + + pub fn remove() { + let _ = std::fs::remove_file(runtime_info()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HumnestRuntimeInfo { + pub socket: PathBuf, + pub pid: u32, + pub version: String, + pub bound_at_ms: u64, +} + +impl HumnestRuntimeInfo { + pub fn read() -> Option { + let raw = std::fs::read_to_string(humnest_runtime()).ok()?; + serde_json::from_str(&raw).ok() + } + + pub fn write(&self) -> std::io::Result<()> { + let path = humnest_runtime(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + let body = serde_json::to_string_pretty(self) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(&tmp, body)?; + std::fs::rename(tmp, path) + } + + pub fn remove() { + let _ = std::fs::remove_file(humnest_runtime()); + } +} + /// `hum.json` (daemon policy). pub fn hum_json() -> PathBuf { config_dir().join("hum.json") } @@ -98,7 +179,7 @@ pub fn peers_json() -> PathBuf { config_dir().join("peers.json") } /// Drift rings directory (`drift/YYYY-MM-DD.ndjson`). pub fn drift_dir() -> PathBuf { state_dir().join("drift") } -/// Cloned hum source tree (recipes + svc.sh helpers). +/// Cloned hum source tree (recipes + hive installers). pub fn src_dir() -> PathBuf { data_dir().join("src") } /// Installed humd binary location. diff --git a/hum.schema.json b/hum.schema.json index 0ba98fd1..9ef3f9e2 100644 --- a/hum.schema.json +++ b/hum.schema.json @@ -89,6 +89,31 @@ "description": "Hive name humd routes to when a chi:prompt doesn't pin one. A hive configures its own runtime (binary, models) via its service env, not here." } } + }, + + "humnest": { + "type": "object", + "additionalProperties": false, + "description": "Bee supervisor — humnest reads bees[] and spawns each.", + "properties": { + "bees": { + "type": "array", + "default": [], + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { "type": "string", "description": "Hive kind, e.g. 'claude-cli'." }, + "argv": { "type": "array", "items": { "type": "string" }, "default": [] }, + "env": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} }, + "restart": { "type": "string", "enum": ["always", "on-failure", "never"], "default": "always" }, + "maxRetries": { "type": "integer", "minimum": 0, "default": 10 }, + "backoffMs": { "type": "integer", "minimum": 0, "default": 1000 } + } + } + } + } } } } diff --git a/hum/src/main.rs b/hum/src/main.rs index 404cccef..45dca0ce 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -21,7 +21,7 @@ //! hum version print version //! hum help print this surface -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result}; @@ -77,6 +77,8 @@ enum Cmd { #[arg(long)] list: bool, }, + /// List humnest-supervised bees with status (kind, pid, state, restart_count). + Nest, /// Show lifetime counters from penny.json Penny, /// List available recipes (recipes/*) or run one @@ -106,6 +108,7 @@ fn main() -> Result<()> { Some(Cmd::Doctor) => doctor(), Some(Cmd::Hive { target, action, list }) => hive(target, action, list), Some(Cmd::Bee { target, verb, list }) => bee(target, verb, list), + Some(Cmd::Nest) => nest(), Some(Cmd::Penny) => penny(), Some(Cmd::Recipes { name }) => recipes(name), Some(Cmd::Uninstall) => uninstall(), @@ -174,6 +177,31 @@ fn latest_release_tag() -> Option { // ─── helpers ───────────────────────────────────────────────────────────── +fn now_ms() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) +} + +fn probe_thrum(sock: &Path) -> Result<()> { + use std::io::{Read, Write}; + use std::os::unix::net::UnixStream; + use std::time::Duration; + if !sock.exists() { + anyhow::bail!("socket file missing (humd not running)"); + } + let mut s = UnixStream::connect(sock) + .map_err(|e| anyhow::anyhow!("connect refused ({e}) — stale socket, humd crashed"))?; + s.set_read_timeout(Some(Duration::from_secs(1)))?; + s.set_write_timeout(Some(Duration::from_secs(1)))?; + s.write_all(b"{\"chi\":\"hello\",\"sid\":\"hum-doctor-probe\",\"bee\":[\"worker\"]}\n")?; + let mut buf = [0u8; 256]; + match s.read(&mut buf) { + Ok(0) => anyhow::bail!("socket closed without breath"), + Ok(_) => Ok(()), + Err(e) => anyhow::bail!("no breath within 1s ({e})"), + } +} + fn humd_bin() -> Result { let candidates = [ std::env::var_os("HUM_BIN").map(PathBuf::from), @@ -205,7 +233,7 @@ fn summary() -> Result<()> { } fn status() -> Result<()> { - let thrum_sock = hum_paths::thrum_sock(); + let thrum_sock = hum_paths::thrum_sock_resolved(); let bin = humd_bin().ok(); let bin_display = bin.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "(missing)".into()); @@ -323,8 +351,25 @@ fn doctor() -> Result<()> { let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default(); let runtime_exists = std::path::Path::new(&runtime).is_dir(); println!(" XDG_RUNTIME_DIR: {runtime} {}", if runtime_exists { "✓" } else { "✗ DOES NOT EXIST (penny writes will fail — common macOS trap when set to a Linux /run/user path)" }); - let sock = hum_paths::thrum_sock(); - println!(" thrum sock: {} {}", sock.display(), if std::fs::metadata(&sock).is_ok() { "✓ present" } else { "✗ MISSING (humd not running?)" }); + + match hum_paths::RuntimeInfo::read() { + Some(rt) => { + let age_s = (now_ms() as i64 - rt.bound_at_ms as i64).max(0) / 1000; + println!(" runtime.json: ✓ pid={} version={} bound {}s ago", rt.pid, rt.version, age_s); + } + None => println!(" runtime.json: ✗ MISSING (humd has not published a rendezvous; either not running, or pre-0.31.19)"), + } + + let sock = hum_paths::thrum_sock_resolved(); + match probe_thrum(&sock) { + Ok(()) => println!(" thrum sock: {} ✓ live", sock.display()), + Err(e) => println!(" thrum sock: {} ✗ {e}", sock.display()), + } + + match hum_paths::HumnestRuntimeInfo::read() { + Some(rt) => println!(" humnest: ✓ pid={} version={} sock={}", rt.pid, rt.version, rt.socket.display()), + None => println!(" humnest: ✗ MISSING (no humnest_runtime.json — humnest not running)"), + } // 4. The claude binary (worker's compute). println!("\n[claude binary]"); @@ -404,6 +449,17 @@ fn svc_active(svc: &std::path::Path, unit: &str) -> bool { .unwrap_or(false) } +/// Last exit code reported by the service manager. None if unknown. +/// Non-zero with `!svc_active` means crash-loop. +fn svc_last_exit(svc: &std::path::Path, unit: &str) -> Option { + let out = Command::new("bash") + .arg("-c") + .arg(format!(". {} && svc_last_exit {}", svc.display(), unit)) + .output().ok()?; + let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); + raw.parse().ok() +} + /// Resolve a user-given name to installed service unit(s), tolerantly: /// exact unit ("hum-claude-cli-worker") /// → "hum-" ("paid-oracle" → "hum-paid-oracle") @@ -580,9 +636,12 @@ fn bee_list_full(svc: &std::path::Path, installed: &[String]) -> Result<()> { let provides = arr(m, "provides").iter().filter_map(|x| x.as_str().map(str::to_string)).collect::>(); let wire = m.get("propensity").map(|p| s(p, "wire")).unwrap_or_default(); let state = match &unit { - Some(u) if svc_active(svc, u) => "in nest (service running)", - Some(_) => "in nest (service stopped?)", - None => "in nest (unmanaged)", + Some(u) if svc_active(svc, u) => "in nest (service running)".to_string(), + Some(u) => match svc_last_exit(svc, u) { + Some(code) if code != 0 => format!("⚠ crash-looping (exit {code})"), + _ => "in nest (service stopped?)".to_string(), + }, + None => "in nest (unmanaged)".to_string(), }; println!("● {hive} — {state}"); @@ -601,10 +660,16 @@ fn bee_list_full(svc: &std::path::Path, installed: &[String]) -> Result<()> { println!(); } - // Installed services with no live manifest — bee is exited / not connected. for u in installed { if matched_units.contains(u) { continue; } - let state = if svc_active(svc, u) { "service running, not handshaked" } else { "exited" }; + let state = if svc_active(svc, u) { + "service running, not handshaked".to_string() + } else { + match svc_last_exit(svc, u) { + Some(code) if code != 0 => format!("⚠ crash-looping (exit {code})"), + _ => "exited".to_string(), + } + }; println!("● {} — {state}", u.strip_prefix("hum-").unwrap_or(u)); println!(" service: {u}"); println!(); @@ -632,6 +697,12 @@ fn bee(target: Option, verb: Option, list: bool) -> Result<()> { (Some(t), None) => anyhow::bail!("hum bee {t} — enter | exit | reenter"), _ => anyhow::bail!("hum bee , or hum bee --list"), }; + // Prefer humnest for any kind it knows about; fall back to svc.sh for + // legacy units or unknown targets (svc_active/svc_last_exit helpers + // stay live so `hum bee --list` keeps working). + if target != "all" && humnest_route_verb(&target, &verb)? { + return Ok(()); + } let op = match verb.as_str() { "enter" => "svc_start", "exit" => "svc_stop", @@ -717,6 +788,93 @@ fn repo_root_or_install_dir() -> PathBuf { PathBuf::from(".") } +// ── humnest RPC ────────────────────────────────────────────────────────── + +/// Single-shot NDJSON exchange with humnest over its unix socket. +fn humnest_rpc(tone: serde_json::Value) -> anyhow::Result { + use std::io::{Write, BufRead, BufReader}; + use std::os::unix::net::UnixStream; + use std::time::Duration; + let sock = hum_paths::humnest_sock(); + let mut s = UnixStream::connect(&sock) + .map_err(|e| anyhow::anyhow!("connect humnest at {}: {e}", sock.display()))?; + s.set_read_timeout(Some(Duration::from_secs(2)))?; + s.set_write_timeout(Some(Duration::from_secs(2)))?; + let line = format!("{}\n", tone); + s.write_all(line.as_bytes())?; + let mut reader = BufReader::new(s); + let mut buf = String::new(); + reader.read_line(&mut buf)?; + let reply: serde_json::Value = serde_json::from_str(buf.trim())?; + Ok(reply) +} + +/// Kinds humnest knows about (hum.json humnest.bees[].kind). +fn humnest_catalog() -> Vec { + let hum_json = hum_paths::hum_json(); + let Ok(raw) = std::fs::read_to_string(&hum_json) else { return Vec::new(); }; + let Ok(v) = serde_json::from_str::(&raw) else { return Vec::new(); }; + v.get("humnest").and_then(|h| h.get("bees")).and_then(|b| b.as_array()) + .map(|arr| arr.iter() + .filter_map(|b| b.get("kind").and_then(|k| k.as_str()).map(str::to_string)) + .collect()) + .unwrap_or_default() +} + +fn nest() -> Result<()> { + let reply = humnest_rpc(serde_json::json!({"chi":"humnest-list"}))?; + if reply.get("chi").and_then(|c| c.as_str()) == Some("humnest-err") { + let msg = reply.get("message").and_then(|m| m.as_str()).unwrap_or("unknown"); + anyhow::bail!("humnest: {msg}"); + } + let bees = reply.get("bees").and_then(|b| b.as_array()).cloned().unwrap_or_default(); + if bees.is_empty() { + println!("no bees in humnest (configure hum.json humnest.bees)."); + return Ok(()); + } + println!(" {:<18} {:<8} {:<12} {:<8} {}", "KIND", "PID", "STATE", "RESTARTS", "LAST_EXIT"); + for b in &bees { + let kind = b.get("kind").and_then(|x| x.as_str()).unwrap_or("?"); + let pid = b.get("pid").and_then(|x| x.as_u64()).map(|n| n.to_string()).unwrap_or_else(|| "—".into()); + let state = b.get("state").and_then(|x| x.as_str()).unwrap_or("?"); + let restarts = b.get("restart_count").and_then(|x| x.as_u64()).unwrap_or(0); + let last = b.get("last_exit_code").and_then(|x| x.as_i64()).map(|n| n.to_string()).unwrap_or_else(|| "—".into()); + println!(" {:<18} {:<8} {:<12} {:<8} {}", kind, pid, state, restarts, last); + } + Ok(()) +} + +/// Route enter/exit/reenter through humnest. Returns Ok(true) if humnest +/// handled the verb, Ok(false) if the kind is not in humnest's catalog. +fn humnest_route_verb(kind: &str, verb: &str) -> Result { + if !humnest_catalog().iter().any(|k| k == kind) { + return Ok(false); + } + let tones: Vec = match verb { + "enter" => vec![serde_json::json!({"chi":"humnest-spawn","kind":kind})], + "exit" => vec![serde_json::json!({"chi":"humnest-kill","kind":kind})], + "reenter" => vec![ + serde_json::json!({"chi":"humnest-kill","kind":kind}), + serde_json::json!({"chi":"humnest-spawn","kind":kind}), + ], + other => anyhow::bail!("unknown verb '{other}' (enter | exit | reenter)"), + }; + let past = match verb { "enter" => "entered", "exit" => "exited", _ => "re-entered" }; + for tone in tones { + let reply = humnest_rpc(tone)?; + match reply.get("chi").and_then(|c| c.as_str()) { + Some("humnest-ok") => {} + Some("humnest-err") => { + let msg = reply.get("message").and_then(|m| m.as_str()).unwrap_or("unknown"); + anyhow::bail!("humnest: {msg}"); + } + other => anyhow::bail!("humnest: unexpected reply chi={:?}", other), + } + } + println!(" ✓ {past} {kind} (humnest)"); + Ok(true) +} + fn uninstall() -> Result<()> { let svc = svc_helper().context("scripts/svc.sh not found")?; let script = format!(r#" diff --git a/humctl/Cargo.toml b/humctl/Cargo.toml new file mode 100644 index 00000000..fded42eb --- /dev/null +++ b/humctl/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "humctl" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "humctl — tiny wrapper around the service-manager crate. Installs / runs the two hum daemons (humd, humnest) as user services on systemd or launchd." + +[[bin]] +name = "humctl" +path = "src/main.rs" + +[dependencies] +service-manager = "0.7" +anyhow = { workspace = true } diff --git a/humctl/src/main.rs b/humctl/src/main.rs new file mode 100644 index 00000000..128f46a7 --- /dev/null +++ b/humctl/src/main.rs @@ -0,0 +1,214 @@ +//! humctl — wrap the `service-manager` crate so the hum installer can register +//! and drive the two long-running hum daemons (`humd`, `humnest`) without +//! shelling through a bash compatibility layer. +//! +//! Surface: +//! humctl install {humd|humnest} +//! humctl uninstall {humd|humnest} +//! humctl start {humd|humnest} +//! humctl stop {humd|humnest} +//! humctl restart {humd|humnest} +//! humctl status {humd|humnest} +//! +//! Both daemons are registered at `ServiceLevel::User`; the binary path is +//! `~/.local/bin/` (where `cargo install --root ~/.local` lands them). +//! Linux uses systemd --user; macOS uses launchd LaunchAgents. Status / restart +//! aren't in the `service-manager` trait, so we shell out to the native CLI for +//! those — the rest goes through the crate. + +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::{Command, ExitCode}; + +use anyhow::{anyhow, bail, Context, Result}; +use service_manager::{ + ServiceInstallCtx, ServiceLabel, ServiceLevel, ServiceManager, ServiceStartCtx, ServiceStopCtx, + ServiceUninstallCtx, +}; + +const USAGE: &str = "\ +humctl — manage hum's user-level services. + +Usage: + humctl install {humd|humnest} + humctl uninstall {humd|humnest} + humctl start {humd|humnest} + humctl stop {humd|humnest} + humctl restart {humd|humnest} + humctl status {humd|humnest} +"; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("humctl: {e:#}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result<()> { + let mut args = std::env::args().skip(1); + let verb = args.next().ok_or_else(|| anyhow!("{USAGE}"))?; + if verb == "--help" || verb == "-h" || verb == "help" { + print!("{USAGE}"); + return Ok(()); + } + let unit = args.next().ok_or_else(|| anyhow!("{USAGE}"))?; + let spec = UnitSpec::resolve(&unit)?; + + match verb.as_str() { + "install" => install(&spec), + "uninstall" => uninstall(&spec), + "start" => start(&spec), + "stop" => stop(&spec), + "restart" => restart(&spec), + "status" => status(&spec), + other => bail!("unknown verb '{other}'\n{USAGE}"), + } +} + +/// One of the two hum daemons we know how to register. +struct UnitSpec { + /// Service label. We use application-only labels so unit files come out as + /// `humd.service` / `humnest.service` on Linux and `humd.plist` / + /// `humnest.plist` on macOS — readable, no hum prefix collision. + label: ServiceLabel, + /// Absolute path to the daemon binary. cargo-install lands it at + /// `$HOME/.local/bin/`. + program: PathBuf, +} + +impl UnitSpec { + fn resolve(name: &str) -> Result { + match name { + "humd" | "humnest" => Ok(Self { + label: ServiceLabel { + qualifier: None, + organization: None, + application: name.to_string(), + }, + program: home_dir()?.join(".local").join("bin").join(name), + }), + other => bail!("unknown unit '{other}' (expected humd or humnest)"), + } + } +} + +fn home_dir() -> Result { + std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("$HOME is not set; humctl runs as a user, not as root via sudo")) +} + +fn manager() -> Result> { + let mut mgr = ::native() + .context("no native service manager available on this OS")?; + mgr.set_level(ServiceLevel::User) + .context("service manager does not support user-level services on this OS")?; + Ok(mgr) +} + +fn install(spec: &UnitSpec) -> Result<()> { + if !spec.program.exists() { + bail!( + "binary {} not found — run `cargo install --path {} --root $HOME/.local` first", + spec.program.display(), + spec.label.application + ); + } + let ctx = ServiceInstallCtx { + label: spec.label.clone(), + program: spec.program.clone(), + args: Vec::::new(), + contents: None, + username: None, + working_directory: None, + environment: None, + autostart: true, + }; + manager()?.install(ctx).with_context(|| { + format!( + "installing user service '{}' failed", + spec.label.application + ) + }) +} + +fn uninstall(spec: &UnitSpec) -> Result<()> { + let ctx = ServiceUninstallCtx { + label: spec.label.clone(), + }; + manager()?.uninstall(ctx).with_context(|| { + format!( + "uninstalling user service '{}' failed", + spec.label.application + ) + }) +} + +fn start(spec: &UnitSpec) -> Result<()> { + let ctx = ServiceStartCtx { + label: spec.label.clone(), + }; + manager()? + .start(ctx) + .with_context(|| format!("starting '{}' failed", spec.label.application)) +} + +fn stop(spec: &UnitSpec) -> Result<()> { + let ctx = ServiceStopCtx { + label: spec.label.clone(), + }; + manager()? + .stop(ctx) + .with_context(|| format!("stopping '{}' failed", spec.label.application)) +} + +fn restart(spec: &UnitSpec) -> Result<()> { + // service-manager 0.7 has no restart verb on the trait. Stop is best-effort + // (unit may already be down); start must succeed. + let _ = stop(spec); + start(spec) +} + +/// `status` isn't on the trait either, so shell out to the native CLI. We keep +/// this thin: print whatever the supervisor says and return its exit code. +fn status(spec: &UnitSpec) -> Result<()> { + let name = &spec.label.application; + #[cfg(target_os = "linux")] + let status = Command::new("systemctl") + .args(["--user", "status", "--no-pager", name]) + .status() + .context("failed to spawn systemctl")?; + #[cfg(target_os = "macos")] + let status = { + let uid = unsafe { libc_geteuid() }; + let target = format!("gui/{uid}/{name}"); + Command::new("launchctl") + .args(["print", &target]) + .status() + .context("failed to spawn launchctl")? + }; + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let status = bail!("status is only implemented for linux + macos"); + + if status.success() { + Ok(()) + } else { + // Code 3 from `systemctl status` means "not active" — surface it, but + // don't decorate it as an error. The caller decides what to do. + std::process::exit(status.code().unwrap_or(1)); + } +} + +#[cfg(target_os = "macos")] +extern "C" { + fn geteuid() -> u32; +} + +#[cfg(target_os = "macos")] +unsafe fn libc_geteuid() -> u32 { + geteuid() +} diff --git a/humd/examples/smoke.rs b/humd/examples/smoke.rs index d5045f55..a1078f12 100644 --- a/humd/examples/smoke.rs +++ b/humd/examples/smoke.rs @@ -23,7 +23,7 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); const READ_TIMEOUT: Duration = Duration::from_secs(5); fn socket_path() -> PathBuf { - hum_paths::thrum_sock() + hum_paths::thrum_sock_resolved() } #[tokio::main(flavor = "current_thread")] diff --git a/humd/src/lib.rs b/humd/src/lib.rs index bb6ccd61..58f25a6a 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -17,7 +17,7 @@ use anyhow::Result; use ensemble::{Ensemble, HumdAddr, Hid, HumdKey, PeerCapabilities}; use parking_lot::RwLock; use serde_json::Value; -use thrumd::{serve as thrum_serve, Thrum, Tone, ToneSink}; +use thrumd::{serve_with_hook as thrum_serve_with_hook, Thrum, Tone, ToneSink}; use thrum_core::{Chi, WaneTracker}; use tracing::{info, trace, warn}; @@ -261,10 +261,29 @@ where if bind_thrum { let thrum = thrum.clone(); let path = cfg.thrum_path.clone(); + let humd_version = env!("CARGO_PKG_VERSION").to_string(); tokio::spawn(async move { - if let Err(e) = thrum_serve(thrum, &path).await { + let res = thrum_serve_with_hook(thrum, &path, move |bound| { + let info = hum_paths::RuntimeInfo { + socket: bound.to_path_buf(), + pid: std::process::id(), + version: humd_version, + thrum_version: thrum_core::THRUM_VERSION.to_string(), + bound_at_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64).unwrap_or(0), + ensemble_addrs: Vec::new(), + }; + if let Err(e) = info.write() { + warn!(err = %e, "humd.runtime_info.write_failed"); + } else { + info!(path = %hum_paths::runtime_info().display(), "humd.runtime_info.published"); + } + }).await; + if let Err(e) = res { warn!(err = %e, "thrum.exit"); } + hum_paths::RuntimeInfo::remove(); }); } else { trace!("thrum.override.installed"); diff --git a/humnest/Cargo.toml b/humnest/Cargo.toml new file mode 100644 index 00000000..ebe5a947 --- /dev/null +++ b/humnest/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "humnest" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Bee supervisor daemon. Reads hum.json humnest.bees, spawns each as a supervised child (nest::lifecycle), restarts per policy. Sibling to humd." + +[[bin]] +name = "humnest" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[dependencies] +hum-paths = { path = "../hum-paths" } +config = { path = "../config" } +nest = { path = "../nest" } +thrum-core = { path = "../thrum-core" } +thrumd = { path = "../thrumd" } +tokio = { workspace = true } +tokio-util = "0.7" +command-group = { version = "5", features = ["with-tokio"] } +parking_lot = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +metrics = "0.23" + +[dev-dependencies] +tempfile = "3" diff --git a/humnest/src/control.rs b/humnest/src/control.rs new file mode 100644 index 00000000..d21532b5 --- /dev/null +++ b/humnest/src/control.rs @@ -0,0 +1,91 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::task::JoinHandle; +use tracing::{info, trace, warn}; + +use crate::supervisor::Supervisor; + +pub async fn serve(path: PathBuf, supervisor: Arc) -> Result> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + if path.exists() { + std::fs::remove_file(&path).ok(); + } + let listener = UnixListener::bind(&path)?; + info!(path = %path.display(), "humnest.control.listening"); + + let info = hum_paths::HumnestRuntimeInfo { + socket: path.clone(), + pid: std::process::id(), + version: env!("CARGO_PKG_VERSION").to_string(), + bound_at_ms: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64).unwrap_or(0), + }; + if let Err(e) = info.write() { + warn!(err = %e, "humnest.runtime.write_failed"); + } + + let handle = tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((sock, _)) => { + let sup = supervisor.clone(); + tokio::spawn(async move { handle_conn(sock, sup).await; }); + } + Err(e) => warn!(err = %e, "humnest.control.accept_failed"), + } + } + }); + Ok(handle) +} + +async fn handle_conn(stream: UnixStream, supervisor: Arc) { + let (r, mut w) = stream.into_split(); + let mut reader = BufReader::new(r).lines(); + while let Ok(Some(line)) = reader.next_line().await { + if line.is_empty() { continue; } + let tone: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { trace!(err = %e, "humnest.parse.skip"); continue; } + }; + let chi = tone.get("chi").and_then(|c| c.as_str()).unwrap_or(""); + let reply = match chi { + "humnest-spawn" => { + let kind = tone.get("kind").and_then(|k| k.as_str()).unwrap_or(""); + spawn_by_kind(&supervisor, kind).await + } + "humnest-kill" => { + let kind = tone.get("kind").and_then(|k| k.as_str()).unwrap_or(""); + match supervisor.kill_one(kind).await { + Ok(_) => json!({"chi":"humnest-ok"}), + Err(e) => json!({"chi":"humnest-err","message":e.to_string()}), + } + } + "humnest-list" => { + let bees = supervisor.list(); + json!({"chi":"humnest-list","bees":bees}) + } + other => json!({"chi":"humnest-err","message":format!("unknown chi: {other}")}), + }; + let line = format!("{}\n", reply); + if w.write_all(line.as_bytes()).await.is_err() { break; } + } +} + +async fn spawn_by_kind(supervisor: &Arc, kind: &str) -> Value { + let cfg = config::load(); + match cfg.humnest.bees.iter().find(|b| b.kind == kind).cloned() { + Some(bc) => match supervisor.clone().spawn_one(bc).await { + Ok(_) => json!({"chi":"humnest-ok"}), + Err(e) => json!({"chi":"humnest-err","message":e.to_string()}), + } + None => json!({"chi":"humnest-err","message":format!("no bee of kind '{kind}' in hum.json")}), + } +} diff --git a/humnest/src/lib.rs b/humnest/src/lib.rs new file mode 100644 index 00000000..0c841e53 --- /dev/null +++ b/humnest/src/lib.rs @@ -0,0 +1,33 @@ +//! humnest — bee supervisor. + +pub mod supervisor; +pub mod control; +pub mod log_capture; + +pub use supervisor::{Supervisor, BeeStatus}; + +use std::sync::Arc; + +use anyhow::Result; +use tracing::info; + +pub async fn run(shutdown: F) -> Result<()> +where F: std::future::Future + Send, +{ + hum_paths::init(); + let cfg = config::load(); + let bees = cfg.humnest.bees.clone(); + info!(count = bees.len(), "humnest.boot"); + + let supervisor = Arc::new(Supervisor::new()); + supervisor.clone().spawn_all(bees).await; + + let socket = hum_paths::humnest_sock(); + let _ctl = control::serve(socket, supervisor.clone()).await?; + + shutdown.await; + info!("humnest.shutdown"); + supervisor.kill_all().await; + hum_paths::HumnestRuntimeInfo::remove(); + Ok(()) +} diff --git a/humnest/src/log_capture.rs b/humnest/src/log_capture.rs new file mode 100644 index 00000000..cd139596 --- /dev/null +++ b/humnest/src/log_capture.rs @@ -0,0 +1,52 @@ +use std::path::PathBuf; + +use tokio::fs::OpenOptions; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{ChildStdout, ChildStderr}; +use tracing::warn; + +fn logs_dir() -> PathBuf { + hum_paths::state_dir().join("logs") +} + +pub fn out_log_path(kind: &str) -> PathBuf { + logs_dir().join(format!("{kind}.out.log")) +} + +pub fn err_log_path(kind: &str) -> PathBuf { + logs_dir().join(format!("{kind}.err.log")) +} + +/// Tail child stdout into the per-bee out log file. Spawns a task that +/// runs until the stream ends. +pub fn pipe_stdout(kind: String, stream: ChildStdout) { + let path = out_log_path(&kind); + tokio::spawn(async move { + if let Err(e) = pipe(stream, path).await { + warn!(%kind, err = %e, "humnest.log.stdout_failed"); + } + }); +} + +pub fn pipe_stderr(kind: String, stream: ChildStderr) { + let path = err_log_path(&kind); + tokio::spawn(async move { + if let Err(e) = pipe(stream, path).await { + warn!(%kind, err = %e, "humnest.log.stderr_failed"); + } + }); +} + +async fn pipe(stream: R, path: PathBuf) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let mut file = OpenOptions::new().create(true).append(true).open(&path).await?; + let mut lines = BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let buf = format!("{line}\n"); + if file.write_all(buf.as_bytes()).await.is_err() { break; } + } + file.flush().await.ok(); + Ok(()) +} diff --git a/humnest/src/main.rs b/humnest/src/main.rs new file mode 100644 index 00000000..4bfe5546 --- /dev/null +++ b/humnest/src/main.rs @@ -0,0 +1,23 @@ +use anyhow::Result; +use tokio::signal::unix::{signal, SignalKind}; +use tracing::trace; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + let filter = EnvFilter::try_from_env("HUM_LOG_LEVEL") + .unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::fmt().with_env_filter(filter).with_target(false).compact().init(); + + humnest::run(wait_for_shutdown()).await +} + +async fn wait_for_shutdown() { + let mut term = signal(SignalKind::terminate()).expect("SIGTERM"); + let mut int = signal(SignalKind::interrupt()).expect("SIGINT"); + tokio::select! { + _ = tokio::signal::ctrl_c() => trace!("humnest.shutdown.ctrl-c"), + _ = term.recv() => trace!("humnest.shutdown.sigterm"), + _ = int.recv() => trace!("humnest.shutdown.sigint"), + } +} diff --git a/humnest/src/supervisor.rs b/humnest/src/supervisor.rs new file mode 100644 index 00000000..c473e124 --- /dev/null +++ b/humnest/src/supervisor.rs @@ -0,0 +1,187 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use command_group::AsyncCommandGroup; +use parking_lot::RwLock; +use tokio::process::Command; +use tokio_util::sync::CancellationToken; +use tracing::{info, warn}; +use serde::Serialize; + +use config::BeeConfig; +use nest::lifecycle; + +#[derive(Debug, Clone)] +pub enum RestartPolicy { + Always, + OnFailure { max_retries: u32, backoff_ms: u64 }, + Never, +} + +impl RestartPolicy { + fn from_config(c: &BeeConfig) -> Self { + match c.restart.as_str() { + "always" => RestartPolicy::Always, + "on-failure" => RestartPolicy::OnFailure { max_retries: c.max_retries, backoff_ms: c.backoff_ms }, + "never" => RestartPolicy::Never, + _ => RestartPolicy::Always, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct BeeStatus { + pub kind: String, + pub pid: Option, + pub state: String, // "running" | "exited" | "crash-loop" | "stopped" + pub restart_count: u32, + pub last_exit_code: Option, +} + +struct BeeSlot { + kind: String, + cancel: CancellationToken, + pid: Option, + state: String, + restart_count: u32, + last_exit_code: Option, +} + +pub struct Supervisor { + slots: RwLock>>>, +} + +impl Supervisor { + pub fn new() -> Self { + Self { slots: RwLock::new(HashMap::new()) } + } + + pub async fn spawn_all(self: Arc, bees: Vec) { + for b in bees { + if let Err(e) = self.clone().spawn_one(b).await { + warn!(err = %e, "humnest.spawn.failed"); + } + } + } + + pub async fn spawn_one(self: Arc, cfg: BeeConfig) -> Result<()> { + let kind = cfg.kind.clone(); + let policy = RestartPolicy::from_config(&cfg); + let cancel = CancellationToken::new(); + let slot = Arc::new(RwLock::new(BeeSlot { + kind: kind.clone(), + cancel: cancel.clone(), + pid: None, + state: "spawning".into(), + restart_count: 0, + last_exit_code: None, + })); + self.slots.write().insert(kind.clone(), slot.clone()); + + let this = self.clone(); + tokio::spawn(async move { + let mut retries = 0u32; + loop { + let argv = if cfg.argv.is_empty() { + vec![format!("hum-{}-worker", cfg.kind)] + } else { cfg.argv.clone() }; + + let mut cmd = Command::new(&argv[0]); + cmd.args(&argv[1..]); + for (k, v) in &cfg.env { cmd.env(k, v); } + + let mut child = match cmd.group_spawn() { + Ok(c) => c, + Err(e) => { + warn!(kind = %cfg.kind, err = %e, "humnest.bee.spawn_failed"); + slot.write().state = "exited".into(); + return; + } + }; + let pid = child.inner().id(); + slot.write().pid = pid; + slot.write().state = "running".into(); + info!(kind = %cfg.kind, pid = ?pid, "humnest.bee.spawned"); + + let (rx_exit, child_cancel) = lifecycle::supervise(child); + + tokio::select! { + code = rx_exit => { + let code = code.unwrap_or(1); + slot.write().last_exit_code = Some(code); + slot.write().pid = None; + info!(kind = %cfg.kind, code, "humnest.bee.exited"); + + if cancel.is_cancelled() { + slot.write().state = "stopped".into(); + return; + } + + match &policy { + RestartPolicy::Never => { + slot.write().state = "exited".into(); + return; + } + RestartPolicy::OnFailure { max_retries, .. } if code == 0 => { + slot.write().state = "exited".into(); + return; + } + RestartPolicy::OnFailure { max_retries, backoff_ms } => { + retries += 1; + if retries > *max_retries { + slot.write().state = "crash-loop".into(); + warn!(kind = %cfg.kind, retries, "humnest.bee.crash_loop"); + return; + } + slot.write().restart_count = retries; + tokio::time::sleep(Duration::from_millis(*backoff_ms)).await; + } + RestartPolicy::Always => { + retries += 1; + slot.write().restart_count = retries; + tokio::time::sleep(Duration::from_millis(cfg.backoff_ms)).await; + } + } + } + _ = cancel.cancelled() => { + child_cancel.cancel(); + slot.write().state = "stopped".into(); + info!(kind = %cfg.kind, "humnest.bee.stopping"); + return; + } + } + } + }); + let _ = this; + Ok(()) + } + + pub async fn kill_one(&self, kind: &str) -> Result<()> { + if let Some(slot) = self.slots.read().get(kind).cloned() { + slot.read().cancel.cancel(); + Ok(()) + } else { + anyhow::bail!("no such bee: {kind}"); + } + } + + pub async fn kill_all(&self) { + let cancels: Vec<_> = self.slots.read().values().map(|s| s.read().cancel.clone()).collect(); + for c in cancels { c.cancel(); } + } + + pub fn list(&self) -> Vec { + self.slots.read().values().map(|s| { + let s = s.read(); + BeeStatus { + kind: s.kind.clone(), + pid: s.pid, + state: s.state.clone(), + restart_count: s.restart_count, + last_exit_code: s.last_exit_code, + } + }).collect() + } +} diff --git a/install b/install index db6874a6..d06b1889 100755 --- a/install +++ b/install @@ -1,29 +1,31 @@ #!/usr/bin/env bash -# hum installer — v0.3 (Rust daemon, ensemble mesh, openai-server door). +# hum installer — v0.4 (humd + humnest, both supervised via humctl). # # What this does, in order: -# 1. Ensure prerequisites: claude CLI (≥ MIN_CLAUDE), cargo, pnpm (for bees). -# 2. Build the Rust humd from source — `cargo install --path humd --root $HUM_BIN_ROOT`. +# 1. Ensure prerequisites: claude CLI (≥ MIN_CLAUDE), cargo. +# 2. Build the three Rust binaries via `cargo install --root $HOME/.local`: +# humd — router daemon +# humnest — bee supervisor +# humctl — tiny service-manager wrapper this script drives # 3. Mint Ed25519 identity at $HUM_STATE/humd.key if missing. # 4. Seed an empty peers.json at $HUM_CONFIG/peers.json if missing. # 5. Seed a minimal hum.json at $HUM_CONFIG/hum.json if missing. -# 6. Install systemd --user unit pointing at the Rust binary. -# 7. Build and install the openai-server bee (the 0.3 front door). -# 8. Start the daemon. +# 6. Install + start humd as a user service via humctl. +# 7. Install + start humnest as a user service via humctl. # -# 0.2 → 0.3 is a config-shape change; there is no migration script. Read -# MIGRATING.md for the old-key → new-home table. Re-run this installer -# after editing $HUM_CONFIG/hum.json into the new shape. +# Bees are NOT installed as separate user units. humnest reads +# hum.json humnest.bees and supervises each child itself. Per-hive +# installers append to that list and bounce humnest. # # Idempotent — re-running upgrades in place. # # Usage: # ./install # from a cloned repo -# ./install status # daemon health -# ./install logs # journalctl tail -# ./install uninstall # remove binary + unit (keeps state) +# ./install status # daemon health (both humd + humnest) +# ./install logs # journalctl / launchd log tail +# ./install uninstall # stop + remove both units (keeps state) # ./install purge # remove everything INCLUDING state -# + set -euo pipefail # ─── XDG dirs ──────────────────────────────────────────────────────────────── @@ -34,7 +36,10 @@ HUM_CONFIG="$XDG_CONFIG_HOME/hum" HUM_DATA="$XDG_DATA_HOME/hum" HUM_STATE="$XDG_STATE_HOME/hum" HUM_BIN_ROOT="$HOME/.local" -HUM_BIN="$HUM_BIN_ROOT/bin/humd" +HUMD_BIN="$HUM_BIN_ROOT/bin/humd" +HUMNEST_BIN="$HUM_BIN_ROOT/bin/humnest" +HUMCTL_BIN="$HUM_BIN_ROOT/bin/humctl" +HUM_CLI_BIN="$HUM_BIN_ROOT/bin/hum" if [ -n "${XDG_RUNTIME_DIR:-}" ]; then HUM_RUNTIME="$XDG_RUNTIME_DIR/hum" @@ -45,7 +50,6 @@ fi # resolve here. Override at runtime via HUM_THRUM_SOCK env if you must. THRUM_SOCK="$HUM_RUNTIME/thrum.sock" -OPENCODE_CONFIG="$XDG_CONFIG_HOME/opencode/opencode.json" HUM_REPO_URL="${HUM_REPO_URL:-https://github.com/adiled/hum.git}" HUM_SRC="$HUM_DATA/src" @@ -85,12 +89,11 @@ ensure_min_version() { ensure_prereqs() { ensure_min_version "claude CLI" "claude" "$MIN_CLAUDE" if ! command -v cargo >/dev/null 2>&1; then - # Dev paradigm: if a pre-built humd is already at $HUM_BIN (e.g. - # placed by ./dev/deploy from a root user), allow proceeding so the - # installer can still write the systemd unit + hum.json. Otherwise - # building from source is required. - if [ -x "$HUM_BIN" ]; then - warn "cargo not found; using pre-built humd at $HUM_BIN" + # Dev paradigm: if pre-built binaries are already present (e.g. placed by + # ./dev/deploy), allow proceeding so the installer can still register + # services. Otherwise building from source is required. + if [ -x "$HUMD_BIN" ] && [ -x "$HUMNEST_BIN" ] && [ -x "$HUMCTL_BIN" ]; then + warn "cargo not found; using pre-built binaries in $HUM_BIN_ROOT/bin" SKIP_BUILD=1 else fail "cargo (rustup) not found. Install rustup: https://rustup.rs" @@ -98,29 +101,6 @@ ensure_prereqs() { fi } -# ─── 0.2 detection — guide, don't migrate ─────────────────────────────────── - -flag_0_2_install() { - local LANDMARK_TS="$HUM_DATA/src/humd/humd.ts" - local LANDMARK_UNIT - LANDMARK_UNIT="$(systemctl --user cat hum 2>/dev/null | grep -E '^ExecStart=' | head -1 || true)" - local IS_OLD=false - [ -f "$LANDMARK_TS" ] && IS_OLD=true - case "$LANDMARK_UNIT" in *humd.ts*|*tsx*) IS_OLD=true ;; esac - $IS_OLD || return 0 - - warn "v0.2 install detected (TS daemon landmarks present)." - warn "" - warn " 0.3 has no migration script — config shape changed too much." - warn " Read MIGRATING.md for the old-key → new-home table." - warn "" - warn " Quick path: stop the old service, remove $HUM_DATA/src, edit" - warn " $HUM_CONFIG/hum.json into the namespaced 0.3 shape, re-run this script." - warn "" - warn " Continuing the install will overwrite the systemd unit and" - warn " binary; your hum.json stays untouched." -} - # ─── identity ──────────────────────────────────────────────────────────────── ensure_identity() { @@ -181,19 +161,20 @@ ensure_hum_config() { "maxActiveCells": 4, "cellIdlePruneThresholdMs": 300000, "default": "claude-cli" + }, + "humnest": { + "bees": [] } } JSON log "wrote default hum.json at $CFG" log " edit fs.roots to match where you actually code." - log " models + binary are set per-hive via its install env (CLAUDE_MODELS, CLAUDE_CLI_PATH); opus 4.7 is the claude-cli default." + log " humnest.bees is empty — add bees by running their hives//install." } -# ─── humd binary ───────────────────────────────────────────────────────────── +# ─── build the three Rust binaries ─────────────────────────────────────────── -HUM_CLI_BIN="$HUM_BIN_ROOT/bin/hum" - -build_humd() { +build_binaries() { local SRC if [ -f "$(dirname "$0")/Cargo.toml" ] && grep -q '"humd"' "$(dirname "$0")/Cargo.toml"; then SRC="$(dirname "$0")" @@ -209,159 +190,111 @@ build_humd() { SRC="$HUM_SRC" fi log "building Rust binaries via cargo install (may take a few minutes)" - cargo install --quiet --locked --path "$SRC/humd" --root "$HUM_BIN_ROOT" --force - cargo install --quiet --locked --path "$SRC/hum" --root "$HUM_BIN_ROOT" --force - [ -x "$HUM_BIN" ] || fail "humd binary not at $HUM_BIN after build" + cargo install --quiet --locked --path "$SRC/humd" --root "$HUM_BIN_ROOT" --force + cargo install --quiet --locked --path "$SRC/humnest" --root "$HUM_BIN_ROOT" --force + cargo install --quiet --locked --path "$SRC/humctl" --root "$HUM_BIN_ROOT" --force + cargo install --quiet --locked --path "$SRC/hum" --root "$HUM_BIN_ROOT" --force + [ -x "$HUMD_BIN" ] || fail "humd binary not at $HUMD_BIN after build" + [ -x "$HUMNEST_BIN" ] || fail "humnest binary not at $HUMNEST_BIN after build" + [ -x "$HUMCTL_BIN" ] || fail "humctl binary not at $HUMCTL_BIN after build" [ -x "$HUM_CLI_BIN" ] || fail "hum binary not at $HUM_CLI_BIN after build" - log "humd installed at $HUM_BIN" - log "hum installed at $HUM_CLI_BIN" + log "humd installed at $HUMD_BIN" + log "humnest installed at $HUMNEST_BIN" + log "humctl installed at $HUMCTL_BIN" + log "hum installed at $HUM_CLI_BIN" } -# ─── service unit (Linux systemd / macOS launchd via scripts/svc.sh) ─────── - -# Resolve and source the cross-platform svc helper. Must run *after* -# build_humd has cloned the repo — on a `curl | bash` install there is -# no local scripts/svc.sh until then, so sourcing at top-level would -# always miss and silently skip service registration. -source_svc_helper() { - command -v svc_install >/dev/null 2>&1 && return 0 # already sourced - local local_helper="$(dirname "$0")/scripts/svc.sh" - if [ -f "$local_helper" ]; then - # shellcheck source=/dev/null - . "$local_helper" - elif [ -f "$HUM_SRC/scripts/svc.sh" ]; then - # shellcheck source=/dev/null - . "$HUM_SRC/scripts/svc.sh" - else - warn "scripts/svc.sh not found; service management will be skipped" - fi -} +# ─── service unit registration via humctl ─────────────────────────────────── -install_service_unit() { - if ! command -v svc_install >/dev/null 2>&1; then - warn "svc helper unavailable — run '$HUM_BIN' manually" - return 0 - fi - case "$SVC_OS" in +# humctl owns OS dispatch (systemd on Linux, launchd on macOS) via the +# service-manager crate. The two units here are user-level; binary paths +# resolve from $HOME/.local/bin/ inside humctl. +install_and_start() { + local name="$1" + case "$OS" in Linux|Darwin) ;; - *) warn "service install not supported on $SVC_OS — run '$HUM_BIN' manually"; return 0 ;; + *) warn "service install not supported on $OS — run '$HUM_BIN_ROOT/bin/$name' manually"; return 0 ;; esac - svc_install hum "$HUM_BIN" \ - --env "HUM_LOG_LEVEL=trace,penny=error" \ - --env "XDG_CONFIG_HOME=$XDG_CONFIG_HOME" \ - --env "XDG_DATA_HOME=$XDG_DATA_HOME" \ - --env "XDG_STATE_HOME=$XDG_STATE_HOME" \ - --env "XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --env "HUM_THRUM_SOCK=$THRUM_SOCK" \ - --env "PATH=$HUM_BIN_ROOT/bin:/usr/local/bin:/usr/bin:/bin" - log "service unit installed for hum ($SVC_OS)" - # humd does its own daily auto-update check in-process — no separate - # timer / cron / launchd interval to maintain. - # Hives are NOT installed here — each hive ships its own installer - # under hives//install. humd just routes; whichever bee you - # bring up registers itself via thrum bee:["worker"] (or "forager"). + "$HUMCTL_BIN" install "$name" || warn "humctl install $name failed" + # restart so re-running the installer picks up new binaries / env + "$HUMCTL_BIN" restart "$name" 2>/dev/null \ + || "$HUMCTL_BIN" start "$name" \ + || warn "humctl start $name failed" + log "$name service registered + started ($OS)" } -start_daemon() { - if ! command -v svc_restart >/dev/null 2>&1; then - warn "svc helper unavailable — start '$HUM_BIN' manually" - return 0 - fi - svc_restart hum 2>/dev/null || svc_start hum 2>/dev/null || true +start_daemons() { + install_and_start humd + install_and_start humnest sleep 1 - if svc_is_active hum; then - log "hum service running ✓" - else - warn "hum service did not start — \`./install logs\` to inspect." - fi -} - -# ─── refresh installed bees ────────────────────────────────────────────────── - -# Re-run the installer for every bee service already present, so an -# update rewrites their unit files (new env like HOME, new flags) and -# rebuilds their binaries from the freshly-pulled source. A first-time -# install has no bee services yet, so this is a no-op then. Units with no -# matching in-repo hive (external/custom bees) are left untouched. -refresh_installed_hives() { - command -v svc_list >/dev/null 2>&1 || return 0 - local hives_dir - if [ -d "$HUM_SRC/hives" ]; then hives_dir="$HUM_SRC/hives" - elif [ -d "$(dirname "$0")/hives" ]; then hives_dir="$(dirname "$0")/hives" - else return 0; fi - - local unit sid d name best - for unit in $(svc_list); do - case "$unit" in hum|hum-update) continue ;; esac - sid="${unit#hum-}" - best="" - for d in "$hives_dir"/*/; do - [ -f "${d}install" ] || continue - name="$(basename "$d")" - case "$sid" in - "$name"|"$name"-*) [ "${#name}" -gt "${#best}" ] && best="$name" ;; - esac - done - if [ -n "$best" ]; then - log "refreshing bee '$best' (unit $unit)" - "$hives_dir/$best/install" || warn "bee refresh failed: $best (continuing)" - else - log "leaving $unit untouched — no in-repo hive matches it" - fi - done } # ─── next steps ────────────────────────────────────────────────────────────── # humd has no opinion on which bees you run. Each bee is a -# separate process with its own installer under hives//install. -# Pick at least one to give humd an outside-world surface. +# separate process; humnest supervises them via hum.json humnest.bees. print_next_steps() { log "" - log "humd is up. Pick a recipe to give it an outside-world surface:" + log "humd + humnest are up. Add a bee to give them an outside-world surface:" log "" log " hum + opencode (chat from the opencode TUI/CLI):" log " ./recipes/opencode/install" log "" - log " Other bees — each is a standalone install:" + log " Other bees — each one appends itself to humnest.bees:" log " hives/openai-server/install (OpenAI-shape HTTP)" - log " hives/anthropic-server/install (Anthropic-shape HTTP)" - log " hives/ollama-server/install (Ollama-shape HTTP)" + log " hives/claude-cli/install (Claude CLI worker)" + log " hives/humfs/install (filesystem forager)" log "" log "Health checks:" - log " ./install status (is humd alive)" - log " ./install logs (recent humd journal)" + log " ./install status (is everything alive)" + log " ./install logs (recent humd + humnest logs)" } # ─── status / logs / uninstall ─────────────────────────────────────────────── show_status() { - echo "humd binary: $HUM_BIN" - echo " version: $($HUM_BIN --version 2>&1 || echo 'not installed')" - echo "identity: $HUM_STATE/humd.key $([ -s "$HUM_STATE/humd.key" ] && echo ✓ || echo MISSING)" - echo "peers.json: $HUM_CONFIG/peers.json $([ -s "$HUM_CONFIG/peers.json" ] && echo ✓ || echo MISSING)" - echo "hum.json: $HUM_CONFIG/hum.json $([ -s "$HUM_CONFIG/hum.json" ] && echo ✓ || echo MISSING)" - echo "thrum socket: $THRUM_SOCK $([ -S "$THRUM_SOCK" ] && echo ✓ || echo absent)" - if command -v svc_status >/dev/null 2>&1; then - echo "service ($SVC_OS):" - svc_status hum 2>&1 | head -6 || true + echo "humd binary: $HUMD_BIN" + echo " version: $($HUMD_BIN --version 2>&1 || echo 'not installed')" + echo "humnest binary: $HUMNEST_BIN" + echo " version: $($HUMNEST_BIN --version 2>&1 || echo 'not installed')" + echo "identity: $HUM_STATE/humd.key $([ -s "$HUM_STATE/humd.key" ] && echo ✓ || echo MISSING)" + echo "peers.json: $HUM_CONFIG/peers.json $([ -s "$HUM_CONFIG/peers.json" ] && echo ✓ || echo MISSING)" + echo "hum.json: $HUM_CONFIG/hum.json $([ -s "$HUM_CONFIG/hum.json" ] && echo ✓ || echo MISSING)" + echo "thrum socket: $THRUM_SOCK $([ -S "$THRUM_SOCK" ] && echo ✓ || echo absent)" + if [ -x "$HUMCTL_BIN" ]; then + echo "service (humd):" + "$HUMCTL_BIN" status humd 2>&1 | head -6 || true + echo "service (humnest):" + "$HUMCTL_BIN" status humnest 2>&1 | head -6 || true + else + echo "humctl not installed — cannot query service status" fi } show_logs() { - case "$SVC_OS" in - Linux) journalctl --user -u hum --no-pager -n 200 ;; - Darwin) tail -n 200 "$HOME/Library/Logs/sh.hum.hum.out.log" "$HOME/Library/Logs/sh.hum.hum.err.log" 2>/dev/null ;; - *) warn "logs command unavailable on $SVC_OS" ;; + case "$OS" in + Linux) + journalctl --user -u humd --no-pager -n 100 + echo "---" + journalctl --user -u humnest --no-pager -n 100 + ;; + Darwin) + tail -n 100 "$HOME/Library/Logs/humd.out.log" "$HOME/Library/Logs/humd.err.log" 2>/dev/null + echo "---" + tail -n 100 "$HOME/Library/Logs/humnest.out.log" "$HOME/Library/Logs/humnest.err.log" 2>/dev/null + ;; + *) warn "logs command unavailable on $OS" ;; esac } uninstall() { - if command -v svc_uninstall >/dev/null 2>&1; then - svc_uninstall hum-update || true - svc_uninstall hum || true + if [ -x "$HUMCTL_BIN" ]; then + "$HUMCTL_BIN" stop humnest 2>/dev/null || true + "$HUMCTL_BIN" stop humd 2>/dev/null || true + "$HUMCTL_BIN" uninstall humnest 2>/dev/null || true + "$HUMCTL_BIN" uninstall humd 2>/dev/null || true fi - rm -f "$HUM_BIN" "$HUM_CLI_BIN" + rm -f "$HUMD_BIN" "$HUMNEST_BIN" "$HUM_CLI_BIN" "$HUMCTL_BIN" log "uninstalled. State preserved in $HUM_STATE + $HUM_CONFIG." log " run \`./install purge\` to remove state too." } @@ -375,28 +308,24 @@ purge() { # ─── dispatch ──────────────────────────────────────────────────────────────── do_install() { - log "hum installer — v0.3" - flag_0_2_install + log "hum installer — v0.4 (humd + humnest)" ensure_prereqs - if [ "${SKIP_BUILD:-0}" != "1" ]; then build_humd; fi - source_svc_helper + if [ "${SKIP_BUILD:-0}" != "1" ]; then build_binaries; fi ensure_identity ensure_peers_config ensure_hum_config - install_service_unit - start_daemon - refresh_installed_hives + start_daemons log "" - log "humd installed. Try: ./install status / ./install logs" + log "humd + humnest installed. Try: ./install status / ./install logs" log "" print_next_steps } case "$CMD" in install|"") do_install ;; - status) source_svc_helper; show_status ;; - logs) source_svc_helper; show_logs ;; - uninstall) source_svc_helper; uninstall ;; - purge) source_svc_helper; purge ;; + status) show_status ;; + logs) show_logs ;; + uninstall) uninstall ;; + purge) purge ;; *) fail "unknown command: $CMD (try: install, status, logs, uninstall, purge)" ;; esac diff --git a/recipes/opencode/install b/recipes/opencode/install index 8ad4dacb..a8d0315e 100755 --- a/recipes/opencode/install +++ b/recipes/opencode/install @@ -5,11 +5,11 @@ # pick provider `hum`, pick a claude model, type, and it works. # # Pipeline: -# 1. invoke ./dev/deploy if present (gets humd live for HUM_DEV_USER) +# 1. invoke ./dev/deploy if present (gets humd + humnest live for HUM_DEV_USER) # 2. build the openai-server bee # 3. seed ~/.config/hum/hives/openai-server.json (target user) # 4. seed/patch ~/.config/opencode/opencode.json with provider.hum -# 5. install hum-openai-server systemd unit + bounce +# 5. (re)register claude-cli worker + openai-server bees with humnest # # Env: # HUM_DEV_USER target user (default: clwnd; same as deploy) @@ -26,13 +26,13 @@ TARGET_UID="$(id -u "$TARGET")" TARGET_RUNTIME="/run/user/$TARGET_UID" PORT="${HUM_OPENAI_PORT:-14620}" -# Step 1 — bring humd up via the env-specific deploy script. +# Step 1 — bring humd + humnest up via the env-specific deploy script. if [ -x "./dev/deploy" ]; then echo "[opencode] running dev/deploy …" ./dev/deploy else - echo "[opencode] no dev/deploy found — assuming humd is already live." - echo " (paradigm 0: cargo run -p humd from another terminal)" + echo "[opencode] no dev/deploy found — assuming humd + humnest are already live." + echo " (paradigm 0: cargo run -p humd + cargo run -p humnest from other terminals)" fi # Step 2 — build the openai-server bee. Each bee is a @@ -72,10 +72,6 @@ su -l "$TARGET" -c "mkdir -p ~/.config/opencode" # OC's openai-compatible provider reads baseURL / apiKey from # `options`. Mirror what the AI SDK actually wants; top-level baseURL # alone produces "undefined/chat/completions" URL parse failures. -# Model list is OC-provider-shape metadata, so it lives next to the -# recipe (./recipes/opencode/models.json). The recipe project the -# bare id-set into OC's `models` map; the rich metadata (cost, family, -# limit) is OC-flavored and isn't published anywhere else. RECIPE_MODELS="./recipes/opencode/models.json" if [ -s "$RECIPE_MODELS" ]; then MODELS_JSON="$(jq '[keys[]] | map({(.): {}}) | add' "$RECIPE_MODELS")" @@ -83,14 +79,6 @@ else echo "[opencode] no $RECIPE_MODELS — using narrow fallback (3 models)" >&2 MODELS_JSON='{"claude-opus-4-7":{},"claude-sonnet-4-6":{},"claude-haiku-4-5":{}}' fi -# Use @ai-sdk/openai (not @ai-sdk/openai-compatible) so OC routes -# through the OpenAI Responses API (/v1/responses). Required for -# provider-executed tool semantics: the worker bee's MCP bridge -# resolves humfs_* tools inline and openai-server emits them as -# `mcp_call` hosted items, which the SDK marks providerExecuted= -# true. OC renders the tool call + result as already-done without -# trying to re-execute (which would land tool: invalid since -# humfs_* aren't in OC's local tool registry). HUM_BLOCK=$(jq -n --argjson models "$MODELS_JSON" '{ npm: "@ai-sdk/openai", options: { baseURL: "http://127.0.0.1:'"$PORT"'/v1", apiKey: "hum-dev" }, @@ -104,12 +92,6 @@ if ! su -l "$TARGET" -c "test -f $OC_CFG"; then } EOF elif command -v jq >/dev/null 2>&1; then - # Always overwrite the hum block — old shape (top-level baseURL, - # no options) silently breaks OC, so reseeding is safer than - # leaving stale config in place. Also strip the 0.2 opencode plugin - # entry (`plugin[]` pointing at the hum src tree) — it tries to - # connect to the legacy thrum socket and fails after 5s, blocking - # every prompt. The 0.3 native openai-compatible provider replaces it. echo "[opencode] (re)writing provider.hum + pruning legacy plugin in $OC_CFG …" su -l "$TARGET" -c " tmp=\$(mktemp) && jq --argjson hum '$HUM_BLOCK' ' @@ -124,35 +106,38 @@ else echo " Add provider.hum manually — see MIGRATING.md." fi -# Step 5 — bring up the claude-cli worker bee. humd is a router; it +# Step 5 — (re)register the claude-cli worker bee. humd is a router; it # can't serve any model without at least one worker registered via thrum. -echo "[opencode] (re)installing hum-claude-cli-worker service …" +# Each hive's installer appends itself to hum.json humnest.bees and +# bounces humnest, so we just invoke them in the target user's shell. +echo "[opencode] (re)registering claude-cli bee with humnest …" su -l "$TARGET" -c "XDG_RUNTIME_DIR=$TARGET_RUNTIME cd ~/.local/share/hum/src/hives/claude-cli && ./install" >/dev/null -# Step 6 — install the openai-server service unit (the nestler side). -echo "[opencode] (re)installing hum-openai-server service …" +# Step 6 — (re)register the openai-server bee. +echo "[opencode] (re)registering openai-server bee with humnest …" su -l "$TARGET" -c "XDG_RUNTIME_DIR=$TARGET_RUNTIME cd ~/.local/share/hum/src/hives/openai-server && ./install" >/dev/null -# Health check via the svc helper — sourced from the rsynced source tree. -SVC_PATH="$TARGET_HOME/.local/share/hum/src/scripts/svc.sh" -if su -l "$TARGET" -c "test -f $SVC_PATH"; then - for svc in hum-claude-cli-worker hum-openai-server; do - if su -l "$TARGET" -c ". $SVC_PATH && XDG_RUNTIME_DIR=$TARGET_RUNTIME svc_is_active $svc"; then - echo "[opencode] $svc active ✓" +# Health check — ask humctl about humnest, and list its bees via the +# control socket if humctl status doesn't say it all. +HUMCTL_BIN="$TARGET_HOME/.local/bin/humctl" +if su -l "$TARGET" -c "test -x $HUMCTL_BIN"; then + for unit in humd humnest; do + if su -l "$TARGET" -c "XDG_RUNTIME_DIR=$TARGET_RUNTIME $HUMCTL_BIN status $unit >/dev/null 2>&1"; then + echo "[opencode] $unit active ✓" else - echo "[opencode] $svc did NOT start cleanly" - echo " Linux: journalctl --user -u $svc" - echo " macOS: tail ~/Library/Logs/sh.hum.$svc.err.log" + echo "[opencode] $unit did NOT start cleanly" + echo " Linux: journalctl --user -u $unit" + echo " macOS: tail ~/Library/Logs/$unit.err.log" fi done else - echo "[opencode] WARN: $SVC_PATH missing; can't health-check" + echo "[opencode] WARN: $HUMCTL_BIN missing; can't health-check" fi echo echo "[opencode] done." echo " humd: $TARGET_RUNTIME/hum/thrum.sock" -echo " claude-cli worker: registered via thrum bee:['worker']" +echo " humnest: supervising claude-cli + openai-server bees" echo " openai-server: http://127.0.0.1:$PORT/v1" echo " opencode cfg: $OC_CFG" echo diff --git a/scripts/svc.sh b/scripts/svc.sh deleted file mode 100755 index 4a27f1d4..00000000 --- a/scripts/svc.sh +++ /dev/null @@ -1,306 +0,0 @@ -#!/bin/bash -# scripts/svc.sh — single point of OS dispatch for user-level -# service management. Sourced by every installer that registers a -# long-running process. -# -# Linux: systemd --user units in $XDG_CONFIG_HOME/systemd/user. -# macOS: launchd LaunchAgents in ~/Library/LaunchAgents. -# Other: graceful warn + return failure; caller decides whether to -# bail or fall back to a paradigm-0 "run it yourself" hint. -# -# Usage: -# source scripts/svc.sh -# svc_install [--env KEY=VAL]... -# svc_start -# svc_restart -# svc_stop -# svc_status -# svc_is_active # 0 if running, 1 otherwise -# svc_uninstall -# -# `name` is the canonical short id (e.g. "hum", "hum-openai-server"). -# The helper picks the right unit filename per OS. - -SVC_OS="$(uname -s)" - -# ─── path resolvers ────────────────────────────────────────────────────── - -_svc_unit_linux() { echo "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$1.service"; } -_svc_unit_darwin() { echo "$HOME/Library/LaunchAgents/sh.hum.$1.plist"; } -_svc_label_darwin(){ echo "sh.hum.$1"; } - -# ─── install ──────────────────────────────────────────────────────────── -# svc_install [--env KEY=VAL]... -# exec_line: full command string ("/path/to/bin --flag") -# --env: repeat as needed to inject Environment / EnvironmentVariables -svc_install() { - local name="$1"; shift - local exec_line="$1"; shift - local envs=() - while [ "$#" -gt 0 ]; do - case "$1" in - --env) envs+=("$2"); shift 2 ;; - *) shift ;; - esac - done - case "$SVC_OS" in - Linux) _svc_install_linux "$name" "$exec_line" "${envs[@]}" ;; - Darwin) _svc_install_darwin "$name" "$exec_line" "${envs[@]}" ;; - *) echo "svc: unsupported OS '$SVC_OS' — run '$exec_line' manually" >&2; return 1 ;; - esac -} - -_svc_install_linux() { - local name="$1"; shift - local exec_line="$1"; shift - local unit; unit="$(_svc_unit_linux "$name")" - mkdir -p "$(dirname "$unit")" - { - echo "[Unit]" - echo "Description=hum service: $name" - echo "After=network.target" - echo "" - echo "[Service]" - echo "Type=simple" - for kv in "$@"; do echo "Environment=$kv"; done - echo "ExecStart=$exec_line" - echo "Restart=on-failure" - echo "RestartSec=2s" - echo "StandardOutput=journal" - echo "StandardError=journal" - echo "SyslogIdentifier=$name" - echo "" - echo "[Install]" - echo "WantedBy=default.target" - } > "$unit" - systemctl --user daemon-reload 2>/dev/null || true - systemctl --user enable "$name" 2>/dev/null || true -} - -_svc_install_darwin() { - local name="$1"; shift - local exec_line="$1"; shift - local plist; plist="$(_svc_unit_darwin "$name")" - local label; label="$(_svc_label_darwin "$name")" - mkdir -p "$(dirname "$plist")" - # Split exec_line into argv, properly handling quoted args. - # Simpler: write a shell wrapper line as `ProgramArguments` via /bin/sh -c - # so users can pass complex flags without our parser fighting them. - { - echo '' - echo '' - echo '' - echo '' - echo " Label$label" - echo ' ProgramArguments' - echo ' ' - echo ' /bin/sh' - echo ' -c' - echo " $exec_line" - echo ' ' - echo ' RunAtLoad' - echo ' KeepAlive' - if [ "$#" -gt 0 ]; then - echo ' EnvironmentVariables' - echo ' ' - for kv in "$@"; do - local k="${kv%%=*}"; local v="${kv#*=}" - echo " $k$v" - done - echo ' ' - fi - echo ' StandardOutPath' - echo " $HOME/Library/Logs/$label.out.log" - echo ' StandardErrorPath' - echo " $HOME/Library/Logs/$label.err.log" - echo '' - echo '' - } > "$plist" -} - -# ─── runtime control ──────────────────────────────────────────────────── - -svc_start() { - case "$SVC_OS" in - Linux) systemctl --user start "$1" 2>/dev/null ;; - Darwin) launchctl bootstrap "gui/$(id -u)" "$(_svc_unit_darwin "$1")" 2>/dev/null || \ - launchctl kickstart "gui/$(id -u)/$(_svc_label_darwin "$1")" 2>/dev/null ;; - *) return 1 ;; - esac -} - -svc_restart() { - case "$SVC_OS" in - Linux) systemctl --user restart "$1" ;; - Darwin) launchctl bootout "gui/$(id -u)/$(_svc_label_darwin "$1")" 2>/dev/null - launchctl bootstrap "gui/$(id -u)" "$(_svc_unit_darwin "$1")" ;; - *) return 1 ;; - esac -} - -svc_stop() { - case "$SVC_OS" in - Linux) systemctl --user stop "$1" 2>/dev/null ;; - Darwin) launchctl bootout "gui/$(id -u)/$(_svc_label_darwin "$1")" 2>/dev/null ;; - *) return 1 ;; - esac -} - -# svc_list — print the canonical short ids of installed bee services, -# one per line. Bees are the `hum-*` units (workers, foragers); the -# bare `hum` daemon is excluded. -svc_list() { - case "$SVC_OS" in - Linux) - local dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" - [ -d "$dir" ] || return 0 - for f in "$dir"/hum-*.service; do - [ -e "$f" ] || continue - local b; b="$(basename "$f")"; echo "${b%.service}" - done - ;; - Darwin) - local dir="$HOME/Library/LaunchAgents" - [ -d "$dir" ] || return 0 - for f in "$dir"/sh.hum.hum-*.plist; do - [ -e "$f" ] || continue - local b; b="$(basename "$f")"; b="${b%.plist}"; echo "${b#sh.hum.}" - done - ;; - *) return 1 ;; - esac -} - -svc_status() { - case "$SVC_OS" in - Linux) systemctl --user status "$1" --no-pager 2>&1 | head -8 ;; - Darwin) launchctl print "gui/$(id -u)/$(_svc_label_darwin "$1")" 2>&1 | head -20 ;; - *) echo "svc: unsupported OS '$SVC_OS'" ;; - esac -} - -svc_is_active() { - case "$SVC_OS" in - Linux) systemctl --user is-active --quiet "$1" ;; - Darwin) launchctl print "gui/$(id -u)/$(_svc_label_darwin "$1")" 2>/dev/null | grep -q 'state = running' ;; - *) return 1 ;; - esac -} - -svc_uninstall() { - case "$SVC_OS" in - Linux) - systemctl --user stop "$1" 2>/dev/null || true - systemctl --user disable "$1" 2>/dev/null || true - rm -f "$(_svc_unit_linux "$1")" - # Sibling timer, if any. - systemctl --user stop "$1.timer" 2>/dev/null || true - systemctl --user disable "$1.timer" 2>/dev/null || true - rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$1.timer" - systemctl --user daemon-reload 2>/dev/null || true - ;; - Darwin) - launchctl bootout "gui/$(id -u)/$(_svc_label_darwin "$1")" 2>/dev/null || true - rm -f "$(_svc_unit_darwin "$1")" - ;; - *) return 1 ;; - esac -} - -# ─── periodic timer ───────────────────────────────────────────────────── -# svc_timer_install [--env KEY=VAL]... -# Installs a recurring trigger that fires . -# uses systemd OnCalendar syntax on Linux (e.g. "daily", "weekly", -# "06:00", "Mon *-*-* 06:00:00"). On macOS, we approximate by -# scheduling a launchd job with StartCalendarInterval — only the -# common rules (daily / weekly / hourly) are translated; anything -# more specific falls back to "daily 03:00 local". -svc_timer_install() { - local name="$1"; shift - local on_calendar="$1"; shift - local exec_line="$1"; shift - local envs=() - while [ "$#" -gt 0 ]; do - case "$1" in - --env) envs+=("$2"); shift 2 ;; - *) shift ;; - esac - done - case "$SVC_OS" in - Linux) - local svc_unit; svc_unit="$(_svc_unit_linux "$name")" - local tmr_unit="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$name.timer" - mkdir -p "$(dirname "$svc_unit")" - # The service unit fires once per timer trigger (Type=oneshot). - { - echo "[Unit]" - echo "Description=hum timer-driven job: $name" - echo "" - echo "[Service]" - echo "Type=oneshot" - for kv in "${envs[@]}"; do echo "Environment=$kv"; done - echo "ExecStart=$exec_line" - } > "$svc_unit" - { - echo "[Unit]" - echo "Description=hum timer: $name (every $on_calendar)" - echo "" - echo "[Timer]" - echo "OnCalendar=$on_calendar" - echo "Persistent=true" - echo "" - echo "[Install]" - echo "WantedBy=timers.target" - } > "$tmr_unit" - systemctl --user daemon-reload 2>/dev/null || true - systemctl --user enable --now "$name.timer" 2>/dev/null || true - ;; - Darwin) - local plist; plist="$(_svc_unit_darwin "$name")" - local label; label="$(_svc_label_darwin "$name")" - # Approximate calendar rules — daily 03:00 is the catch-all. - local hour=3 minute=0 - case "$on_calendar" in - hourly) hour=""; minute=0 ;; - weekly) hour=3; minute=0 ;; - *) hour=3; minute=0 ;; - esac - mkdir -p "$(dirname "$plist")" - { - echo '' - echo '' - echo '' - echo '' - echo " Label$label" - echo ' ProgramArguments' - echo ' ' - echo ' /bin/sh' - echo ' -c' - echo " $exec_line" - echo ' ' - echo ' StartCalendarInterval' - echo ' ' - if [ -n "$hour" ]; then echo " Hour$hour"; fi - echo " Minute$minute" - echo ' ' - if [ "${#envs[@]}" -gt 0 ]; then - echo ' EnvironmentVariables' - echo ' ' - for kv in "${envs[@]}"; do - local k="${kv%%=*}"; local v="${kv#*=}" - echo " $k$v" - done - echo ' ' - fi - echo ' StandardOutPath' - echo " $HOME/Library/Logs/$label.out.log" - echo ' StandardErrorPath' - echo " $HOME/Library/Logs/$label.err.log" - echo '' - echo '' - } > "$plist" - launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null || true - ;; - *) return 1 ;; - esac -} diff --git a/thrumd/src/lib.rs b/thrumd/src/lib.rs index fecb34eb..3d4dc781 100644 --- a/thrumd/src/lib.rs +++ b/thrumd/src/lib.rs @@ -203,9 +203,15 @@ impl Default for Thrum { } } -/// Bind the unix socket and run the accept loop forever. Removes any -/// stale socket file at `path` before binding. Returns on listener error. pub async fn serve(thrum: Thrum, path: impl AsRef) -> Result<()> { + serve_with_hook(thrum, path, |_| {}).await +} + +/// Like [`serve`], but `on_bound` fires after `bind()` succeeds and +/// before the accept loop starts. humd uses this to publish its +/// `runtime.json` rendezvous file. +pub async fn serve_with_hook(thrum: Thrum, path: impl AsRef, on_bound: F) -> Result<()> +where F: FnOnce(&Path) + Send + 'static { let path = path.as_ref(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -217,6 +223,7 @@ pub async fn serve(thrum: Thrum, path: impl AsRef) -> Result<()> { let listener = UnixListener::bind(path) .with_context(|| format!("bind unix socket {:?}", path))?; info!(path = %path.display(), version = %THRUM_VERSION, "thrum.listening"); + on_bound(path); let limiter = RateLimiter::direct(Quota::per_second(std::num::NonZeroU32::new(100).unwrap())); From 9066fc44058a381f69117bfbdabeaa4c8eaab265 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 18:24:03 +0000 Subject: [PATCH 07/18] codegen: python + go clients honor runtime.json rendezvous Same priority order as hum_paths::thrum_sock_resolved on the Rust side: HUM_THRUM_SOCK > runtime.json > computed default. Closes the last gap where non-Rust clients would miss humd's published socket path after restart. --- codegen/src/lib.rs | 25 +++++++++++++++++++++---- thrum-clients/go/thrum/helpers.go | 13 +++++++++++-- thrum-clients/python/thrum/helpers.py | 12 ++++++++++-- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/codegen/src/lib.rs b/codegen/src/lib.rs index c68e7be5..3c2cac85 100644 --- a/codegen/src/lib.rs +++ b/codegen/src/lib.rs @@ -439,6 +439,7 @@ fn render_py_helpers() -> String { from __future__ import annotations import hashlib +import json import os import threading import time @@ -524,12 +525,19 @@ class WaneTracker: def default_socket_path() -> str: - """Resolve the humd thrum socket per WIRE.md priority: - HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/thrum.sock > ~/.local/state/hum/thrum.sock.""" + """Resolve the humd thrum socket: + HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/runtime.json (rendezvous) > $XDG_STATE_HOME/hum/thrum.sock.""" explicit = os.environ.get("HUM_THRUM_SOCK") if explicit: return explicit state = os.environ.get("XDG_STATE_HOME") or os.path.join(os.path.expanduser("~"), ".local", "state") + try: + with open(os.path.join(state, "hum", "runtime.json"), "r") as f: + sock = json.load(f).get("socket") + if sock: + return sock + except (OSError, ValueError): + pass return os.path.join(state, "hum", "thrum.sock") "#; SRC.to_string() @@ -598,6 +606,7 @@ package thrum import ( "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "os" "path/filepath" @@ -689,8 +698,8 @@ func (w *WaneTracker) Behind(sigil string, remote int64) bool { return remote > w.counters[sigil] } -// DefaultSocketPath resolves the humd thrum socket per WIRE.md priority: -// HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/thrum.sock > ~/.local/state/hum/thrum.sock. +// DefaultSocketPath resolves the humd thrum socket: +// HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/runtime.json (rendezvous) > $XDG_STATE_HOME/hum/thrum.sock. func DefaultSocketPath() string { if explicit := os.Getenv("HUM_THRUM_SOCK"); explicit != "" { return explicit @@ -700,6 +709,14 @@ func DefaultSocketPath() string { home, _ := os.UserHomeDir() state = filepath.Join(home, ".local", "state") } + if data, err := os.ReadFile(filepath.Join(state, "hum", "runtime.json")); err == nil { + var rt struct { + Socket string `json:"socket"` + } + if json.Unmarshal(data, &rt) == nil && rt.Socket != "" { + return rt.Socket + } + } return filepath.Join(state, "hum", "thrum.sock") } "#; diff --git a/thrum-clients/go/thrum/helpers.go b/thrum-clients/go/thrum/helpers.go index 69d561b0..455d268c 100644 --- a/thrum-clients/go/thrum/helpers.go +++ b/thrum-clients/go/thrum/helpers.go @@ -8,6 +8,7 @@ package thrum import ( "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "os" "path/filepath" @@ -99,8 +100,8 @@ func (w *WaneTracker) Behind(sigil string, remote int64) bool { return remote > w.counters[sigil] } -// DefaultSocketPath resolves the humd thrum socket per WIRE.md priority: -// HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/thrum.sock > ~/.local/state/hum/thrum.sock. +// DefaultSocketPath resolves the humd thrum socket: +// HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/runtime.json (rendezvous) > $XDG_STATE_HOME/hum/thrum.sock. func DefaultSocketPath() string { if explicit := os.Getenv("HUM_THRUM_SOCK"); explicit != "" { return explicit @@ -110,5 +111,13 @@ func DefaultSocketPath() string { home, _ := os.UserHomeDir() state = filepath.Join(home, ".local", "state") } + if data, err := os.ReadFile(filepath.Join(state, "hum", "runtime.json")); err == nil { + var rt struct { + Socket string `json:"socket"` + } + if json.Unmarshal(data, &rt) == nil && rt.Socket != "" { + return rt.Socket + } + } return filepath.Join(state, "hum", "thrum.sock") } diff --git a/thrum-clients/python/thrum/helpers.py b/thrum-clients/python/thrum/helpers.py index fa6bdc02..99322894 100644 --- a/thrum-clients/python/thrum/helpers.py +++ b/thrum-clients/python/thrum/helpers.py @@ -6,6 +6,7 @@ from __future__ import annotations import hashlib +import json import os import threading import time @@ -91,10 +92,17 @@ def behind(self, sigil: str, remote: int) -> bool: def default_socket_path() -> str: - """Resolve the humd thrum socket per WIRE.md priority: - HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/thrum.sock > ~/.local/state/hum/thrum.sock.""" + """Resolve the humd thrum socket: + HUM_THRUM_SOCK > $XDG_STATE_HOME/hum/runtime.json (rendezvous) > $XDG_STATE_HOME/hum/thrum.sock.""" explicit = os.environ.get("HUM_THRUM_SOCK") if explicit: return explicit state = os.environ.get("XDG_STATE_HOME") or os.path.join(os.path.expanduser("~"), ".local", "state") + try: + with open(os.path.join(state, "hum", "runtime.json"), "r") as f: + sock = json.load(f).get("socket") + if sock: + return sock + except (OSError, ValueError): + pass return os.path.join(state, "hum", "thrum.sock") From c896e6e2c20e62aa1e08c81c4a98d11c868721d2 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 19:31:37 +0000 Subject: [PATCH 08/18] humctl/install/logs cleanup ahead of orchd adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit humctl shrinks to humd-only operator (no install/uninstall — bootstrap owns service registration). New verbs: start, stop, restart, status, logs, health. Status shells systemctl/launchctl native; health does a real connect + hello/breath probe through hum_paths::thrum_sock_resolved. hum_paths::macos_log is replaced by daemon_logs(name) returning a typed DaemonLogs { Journald{unit} | Files{stdout,stderr} } so callers dispatch on the enum instead of #[cfg]-ing per platform. humctl logs and hum's print_recent_logs now share the same path. install bash generates humd.service + humnest.service inline (Linux) or humd.plist + humnest.plist (macOS), coupling expressed in the unit files themselves (Wants= / PartOf= / RunAtLoad). svc.sh stays deleted. SIGHUP reload handler I had added to humnest is reverted — it was a coarse 'reconcile against disk' plaster. surgical RPC (humnest-spawn / humnest-kill via the existing control socket) is the right primitive and hum_route_verb already uses it. --- Cargo.lock | 1 + hum-paths/src/lib.rs | 25 +++-- hum/src/main.rs | 40 ++++---- humctl/Cargo.toml | 3 +- humctl/src/main.rs | 231 +++++++++++++------------------------------ humnest/src/lib.rs | 5 +- humnest/src/main.rs | 1 - install | 149 +++++++++++++++++++--------- 8 files changed, 215 insertions(+), 240 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d51e0ebe..2a7a4170 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1926,6 +1926,7 @@ name = "humctl" version = "0.31.18" dependencies = [ "anyhow", + "hum-paths", "service-manager", ] diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index 951e0d28..baf8d30f 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -195,14 +195,23 @@ pub fn bee_config(kind: &str) -> PathBuf { config_dir().join("bees").join(format!("{kind}.json")) } -/// macOS log paths for a launchd unit short id (e.g. `"hum"`, `"hum-claude-cli-worker"`). -/// Returns `(stdout, stderr)`. -pub fn macos_log(unit: &str) -> (PathBuf, PathBuf) { - let base = home().join("Library/Logs"); - ( - base.join(format!("sh.hum.{unit}.out.log")), - base.join(format!("sh.hum.{unit}.err.log")), - ) +/// Where a hum daemon's logs live, by platform. +pub enum DaemonLogs { + Journald { unit: String }, + Files { stdout: PathBuf, stderr: PathBuf }, +} + +pub fn daemon_logs(name: &str) -> DaemonLogs { + #[cfg(target_os = "macos")] + { + let base = home().join("Library/Logs"); + return DaemonLogs::Files { + stdout: base.join(format!("sh.hum.{name}.out.log")), + stderr: base.join(format!("sh.hum.{name}.err.log")), + }; + } + #[cfg(not(target_os = "macos"))] + DaemonLogs::Journald { unit: name.to_string() } } // ── helpers ─────────────────────────────────────────────────────────────────── diff --git a/hum/src/main.rs b/hum/src/main.rs index 45dca0ce..46456187 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -269,17 +269,17 @@ fn status() -> Result<()> { fn yn(b: bool) -> &'static str { if b { "✓" } else { "missing" } } fn logs(lines: u32) -> Result<()> { - let svc = svc_helper().context("scripts/svc.sh not found — install hum first")?; - let script = format!(r#" - . {svc} - case "$SVC_OS" in - Linux) journalctl --user -u hum --no-pager -n {lines} ;; - Darwin) tail -n {lines} "$HOME/Library/Logs/sh.hum.hum.out.log" \ - "$HOME/Library/Logs/sh.hum.hum.err.log" 2>/dev/null ;; - *) echo "logs unavailable on $SVC_OS" >&2; exit 1 ;; - esac - "#, svc = svc.display()); - Command::new("bash").arg("-c").arg(script).status()?; + match hum_paths::daemon_logs("humd") { + hum_paths::DaemonLogs::Journald { unit } => { + Command::new("journalctl") + .args(["--user", "-u", &unit, "--no-pager", "-n", &lines.to_string()]) + .status()?; + } + hum_paths::DaemonLogs::Files { stdout, stderr } => { + Command::new("tail").args(["-n", &lines.to_string()]) + .arg(stdout).arg(stderr).status()?; + } + } Ok(()) } @@ -400,16 +400,16 @@ fn doctor() -> Result<()> { Ok(()) } -/// Tail a service's recent logs, platform-aware, surfacing warn/error -/// lines (and the key hum diagnostic markers) so the dump stays short. fn print_recent_logs(unit: &str, lines: u32) { - let script = format!(r#" - case "$(uname -s)" in - Linux) journalctl --user -u {unit} --no-pager -n {lines} 2>/dev/null ;; - Darwin) tail -n {lines} "$HOME/Library/Logs/sh.hum.{unit}.out.log" \ - "$HOME/Library/Logs/sh.hum.{unit}.err.log" 2>/dev/null ;; - esac | grep -iE 'WARN|ERROR|result.error|bee\.hid|spawn|panic|fail' | tail -15 - "#); + let raw_cmd = match hum_paths::daemon_logs(unit) { + hum_paths::DaemonLogs::Journald { unit: u } => + format!("journalctl --user -u {u} --no-pager -n {lines} 2>/dev/null"), + hum_paths::DaemonLogs::Files { stdout, stderr } => + format!("tail -n {lines} {} {} 2>/dev/null", stdout.display(), stderr.display()), + }; + let script = format!( + "{raw_cmd} | grep -iE 'WARN|ERROR|result.error|bee\\.hid|spawn|panic|fail' | tail -15" + ); println!(" ── {unit} ──"); let out = Command::new("bash").arg("-c").arg(&script).output(); match out { diff --git a/humctl/Cargo.toml b/humctl/Cargo.toml index fded42eb..31e47203 100644 --- a/humctl/Cargo.toml +++ b/humctl/Cargo.toml @@ -3,7 +3,7 @@ name = "humctl" version.workspace = true edition.workspace = true license.workspace = true -description = "humctl — tiny wrapper around the service-manager crate. Installs / runs the two hum daemons (humd, humnest) as user services on systemd or launchd." +description = "humctl — humd operator. Start, stop, status, logs, health checks for the humd daemon." [[bin]] name = "humctl" @@ -12,3 +12,4 @@ path = "src/main.rs" [dependencies] service-manager = "0.7" anyhow = { workspace = true } +hum-paths = { path = "../hum-paths" } diff --git a/humctl/src/main.rs b/humctl/src/main.rs index 128f46a7..ea5add7c 100644 --- a/humctl/src/main.rs +++ b/humctl/src/main.rs @@ -1,105 +1,60 @@ -//! humctl — wrap the `service-manager` crate so the hum installer can register -//! and drive the two long-running hum daemons (`humd`, `humnest`) without -//! shelling through a bash compatibility layer. -//! -//! Surface: -//! humctl install {humd|humnest} -//! humctl uninstall {humd|humnest} -//! humctl start {humd|humnest} -//! humctl stop {humd|humnest} -//! humctl restart {humd|humnest} -//! humctl status {humd|humnest} -//! -//! Both daemons are registered at `ServiceLevel::User`; the binary path is -//! `~/.local/bin/` (where `cargo install --root ~/.local` lands them). -//! Linux uses systemd --user; macOS uses launchd LaunchAgents. Status / restart -//! aren't in the `service-manager` trait, so we shell out to the native CLI for -//! those — the rest goes through the crate. +//! humctl — humd operator. Bootstrap registers humd as a user service; humctl +//! drives it after that. humnest is opaque here; observe it through `hum nest` +//! and `hum bee info`. -use std::ffi::OsString; -use std::path::PathBuf; use std::process::{Command, ExitCode}; use anyhow::{anyhow, bail, Context, Result}; use service_manager::{ - ServiceInstallCtx, ServiceLabel, ServiceLevel, ServiceManager, ServiceStartCtx, ServiceStopCtx, - ServiceUninstallCtx, + ServiceLabel, ServiceLevel, ServiceManager, ServiceStartCtx, ServiceStopCtx, }; const USAGE: &str = "\ -humctl — manage hum's user-level services. +humctl — operate the humd daemon. Usage: - humctl install {humd|humnest} - humctl uninstall {humd|humnest} - humctl start {humd|humnest} - humctl stop {humd|humnest} - humctl restart {humd|humnest} - humctl status {humd|humnest} + humctl start + humctl stop + humctl restart + humctl status + humctl logs [-n LINES] + humctl health "; fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, - Err(e) => { - eprintln!("humctl: {e:#}"); - ExitCode::from(1) - } + Err(e) => { eprintln!("humctl: {e:#}"); ExitCode::from(1) } } } fn run() -> Result<()> { let mut args = std::env::args().skip(1); let verb = args.next().ok_or_else(|| anyhow!("{USAGE}"))?; - if verb == "--help" || verb == "-h" || verb == "help" { - print!("{USAGE}"); - return Ok(()); - } - let unit = args.next().ok_or_else(|| anyhow!("{USAGE}"))?; - let spec = UnitSpec::resolve(&unit)?; - + if matches!(verb.as_str(), "--help" | "-h" | "help") { print!("{USAGE}"); return Ok(()); } match verb.as_str() { - "install" => install(&spec), - "uninstall" => uninstall(&spec), - "start" => start(&spec), - "stop" => stop(&spec), - "restart" => restart(&spec), - "status" => status(&spec), - other => bail!("unknown verb '{other}'\n{USAGE}"), + "start" => start(), + "stop" => stop(), + "restart" => { let _ = stop(); start() } + "status" => status(), + "logs" => logs(parse_lines(args.collect::>())), + "health" => health(), + other => bail!("unknown verb '{other}'\n{USAGE}"), } } -/// One of the two hum daemons we know how to register. -struct UnitSpec { - /// Service label. We use application-only labels so unit files come out as - /// `humd.service` / `humnest.service` on Linux and `humd.plist` / - /// `humnest.plist` on macOS — readable, no hum prefix collision. - label: ServiceLabel, - /// Absolute path to the daemon binary. cargo-install lands it at - /// `$HOME/.local/bin/`. - program: PathBuf, -} - -impl UnitSpec { - fn resolve(name: &str) -> Result { - match name { - "humd" | "humnest" => Ok(Self { - label: ServiceLabel { - qualifier: None, - organization: None, - application: name.to_string(), - }, - program: home_dir()?.join(".local").join("bin").join(name), - }), - other => bail!("unknown unit '{other}' (expected humd or humnest)"), +fn parse_lines(rest: Vec) -> u32 { + let mut it = rest.iter(); + while let Some(a) = it.next() { + if a == "-n" || a == "--lines" { + if let Some(v) = it.next() { if let Ok(n) = v.parse() { return n; } } } } + 200 } -fn home_dir() -> Result { - std::env::var_os("HOME") - .map(PathBuf::from) - .ok_or_else(|| anyhow!("$HOME is not set; humctl runs as a user, not as root via sudo")) +fn label() -> ServiceLabel { + ServiceLabel { qualifier: None, organization: None, application: "humd".to_string() } } fn manager() -> Result> { @@ -110,105 +65,55 @@ fn manager() -> Result> { Ok(mgr) } -fn install(spec: &UnitSpec) -> Result<()> { - if !spec.program.exists() { - bail!( - "binary {} not found — run `cargo install --path {} --root $HOME/.local` first", - spec.program.display(), - spec.label.application - ); - } - let ctx = ServiceInstallCtx { - label: spec.label.clone(), - program: spec.program.clone(), - args: Vec::::new(), - contents: None, - username: None, - working_directory: None, - environment: None, - autostart: true, - }; - manager()?.install(ctx).with_context(|| { - format!( - "installing user service '{}' failed", - spec.label.application - ) - }) -} - -fn uninstall(spec: &UnitSpec) -> Result<()> { - let ctx = ServiceUninstallCtx { - label: spec.label.clone(), - }; - manager()?.uninstall(ctx).with_context(|| { - format!( - "uninstalling user service '{}' failed", - spec.label.application - ) - }) -} +fn start() -> Result<()> { manager()?.start(ServiceStartCtx { label: label() }).context("start humd") } +fn stop() -> Result<()> { manager()?.stop(ServiceStopCtx { label: label() }).context("stop humd") } -fn start(spec: &UnitSpec) -> Result<()> { - let ctx = ServiceStartCtx { - label: spec.label.clone(), - }; - manager()? - .start(ctx) - .with_context(|| format!("starting '{}' failed", spec.label.application)) -} - -fn stop(spec: &UnitSpec) -> Result<()> { - let ctx = ServiceStopCtx { - label: spec.label.clone(), - }; - manager()? - .stop(ctx) - .with_context(|| format!("stopping '{}' failed", spec.label.application)) -} - -fn restart(spec: &UnitSpec) -> Result<()> { - // service-manager 0.7 has no restart verb on the trait. Stop is best-effort - // (unit may already be down); start must succeed. - let _ = stop(spec); - start(spec) -} - -/// `status` isn't on the trait either, so shell out to the native CLI. We keep -/// this thin: print whatever the supervisor says and return its exit code. -fn status(spec: &UnitSpec) -> Result<()> { - let name = &spec.label.application; +fn status() -> Result<()> { #[cfg(target_os = "linux")] - let status = Command::new("systemctl") - .args(["--user", "status", "--no-pager", name]) - .status() - .context("failed to spawn systemctl")?; + let s = Command::new("systemctl").args(["--user", "status", "--no-pager", "humd"]).status()?; #[cfg(target_os = "macos")] - let status = { - let uid = unsafe { libc_geteuid() }; - let target = format!("gui/{uid}/{name}"); - Command::new("launchctl") - .args(["print", &target]) - .status() - .context("failed to spawn launchctl")? + let s = { + let uid = unsafe { geteuid() }; + Command::new("launchctl").args(["print", &format!("gui/{uid}/humd")]).status()? }; #[cfg(not(any(target_os = "linux", target_os = "macos")))] - let status = bail!("status is only implemented for linux + macos"); + let s = bail!("status is only implemented for linux + macos"); + if !s.success() { std::process::exit(s.code().unwrap_or(1)); } + Ok(()) +} - if status.success() { - Ok(()) - } else { - // Code 3 from `systemctl status` means "not active" — surface it, but - // don't decorate it as an error. The caller decides what to do. - std::process::exit(status.code().unwrap_or(1)); +fn logs(lines: u32) -> Result<()> { + match hum_paths::daemon_logs("humd") { + hum_paths::DaemonLogs::Journald { unit } => { + Command::new("journalctl") + .args(["--user", "-u", &unit, "--no-pager", "-n", &lines.to_string()]) + .status().context("journalctl")?; + } + hum_paths::DaemonLogs::Files { stdout, stderr } => { + Command::new("tail").args(["-n", &lines.to_string()]) + .arg(stdout).arg(stderr).status().context("tail")?; + } } + Ok(()) } -#[cfg(target_os = "macos")] -extern "C" { - fn geteuid() -> u32; +fn health() -> Result<()> { + let sock = hum_paths::thrum_sock_resolved(); + use std::io::{Read, Write}; + use std::os::unix::net::UnixStream; + use std::time::Duration; + if !sock.exists() { bail!("socket file missing: {}", sock.display()); } + let mut s = UnixStream::connect(&sock).with_context(|| format!("connect {}", sock.display()))?; + s.set_read_timeout(Some(Duration::from_secs(1)))?; + s.set_write_timeout(Some(Duration::from_secs(1)))?; + s.write_all(b"{\"chi\":\"hello\",\"sid\":\"humctl-health\",\"bee\":[\"worker\"]}\n")?; + let mut buf = [0u8; 256]; + match s.read(&mut buf) { + Ok(0) => bail!("socket closed without breath"), + Ok(_) => { println!("humd: ✓ live at {}", sock.display()); Ok(()) } + Err(e) => bail!("no breath within 1s: {e}"), + } } #[cfg(target_os = "macos")] -unsafe fn libc_geteuid() -> u32 { - geteuid() -} +extern "C" { fn geteuid() -> u32; } diff --git a/humnest/src/lib.rs b/humnest/src/lib.rs index 0c841e53..fc73738b 100644 --- a/humnest/src/lib.rs +++ b/humnest/src/lib.rs @@ -16,11 +16,10 @@ where F: std::future::Future + Send, { hum_paths::init(); let cfg = config::load(); - let bees = cfg.humnest.bees.clone(); - info!(count = bees.len(), "humnest.boot"); + info!(count = cfg.humnest.bees.len(), "humnest.boot"); let supervisor = Arc::new(Supervisor::new()); - supervisor.clone().spawn_all(bees).await; + supervisor.clone().spawn_all(cfg.humnest.bees).await; let socket = hum_paths::humnest_sock(); let _ctl = control::serve(socket, supervisor.clone()).await?; diff --git a/humnest/src/main.rs b/humnest/src/main.rs index 4bfe5546..38d2adf4 100644 --- a/humnest/src/main.rs +++ b/humnest/src/main.rs @@ -8,7 +8,6 @@ async fn main() -> Result<()> { let filter = EnvFilter::try_from_env("HUM_LOG_LEVEL") .unwrap_or_else(|_| EnvFilter::new("info")); tracing_subscriber::fmt().with_env_filter(filter).with_target(false).compact().init(); - humnest::run(wait_for_shutdown()).await } diff --git a/install b/install index d06b1889..d9badb50 100755 --- a/install +++ b/install @@ -1,17 +1,10 @@ #!/usr/bin/env bash -# hum installer — v0.4 (humd + humnest, both supervised via humctl). +# hum installer. # -# What this does, in order: -# 1. Ensure prerequisites: claude CLI (≥ MIN_CLAUDE), cargo. -# 2. Build the three Rust binaries via `cargo install --root $HOME/.local`: -# humd — router daemon -# humnest — bee supervisor -# humctl — tiny service-manager wrapper this script drives -# 3. Mint Ed25519 identity at $HUM_STATE/humd.key if missing. -# 4. Seed an empty peers.json at $HUM_CONFIG/peers.json if missing. -# 5. Seed a minimal hum.json at $HUM_CONFIG/hum.json if missing. -# 6. Install + start humd as a user service via humctl. -# 7. Install + start humnest as a user service via humctl. +# Builds humd, humnest, humctl, hum. Registers humd + humnest as user +# services (systemd Linux / launchd macOS); humd's unit wants humnest's +# so the two come up together. humctl operates humd at runtime; +# humnest is reached via `hum nest` / `hum bee`. # # Bees are NOT installed as separate user units. humnest reads # hum.json humnest.bees and supervises each child itself. Per-hive @@ -204,28 +197,86 @@ build_binaries() { log "hum installed at $HUM_CLI_BIN" } -# ─── service unit registration via humctl ─────────────────────────────────── +# ─── service unit registration (inline; the install script's concern) ────── + +linux_unit_dir="${XDG_CONFIG_HOME}/systemd/user" +darwin_unit_dir="$HOME/Library/LaunchAgents" + +write_linux_units() { + mkdir -p "$linux_unit_dir" + cat > "$linux_unit_dir/humd.service" < "$linux_unit_dir/humnest.service" </dev/null 2>&1 || true + systemctl --user restart humd.service humnest.service +} -# humctl owns OS dispatch (systemd on Linux, launchd on macOS) via the -# service-manager crate. The two units here are user-level; binary paths -# resolve from $HOME/.local/bin/ inside humctl. -install_and_start() { - local name="$1" - case "$OS" in - Linux|Darwin) ;; - *) warn "service install not supported on $OS — run '$HUM_BIN_ROOT/bin/$name' manually"; return 0 ;; - esac - "$HUMCTL_BIN" install "$name" || warn "humctl install $name failed" - # restart so re-running the installer picks up new binaries / env - "$HUMCTL_BIN" restart "$name" 2>/dev/null \ - || "$HUMCTL_BIN" start "$name" \ - || warn "humctl start $name failed" - log "$name service registered + started ($OS)" +write_darwin_units() { + mkdir -p "$darwin_unit_dir" + cat > "$darwin_unit_dir/humd.plist" < + + + Labelhumd + Program$HUMD_BIN + RunAtLoad + KeepAlive + StandardOutPath$HOME/Library/Logs/sh.hum.humd.out.log + StandardErrorPath$HOME/Library/Logs/sh.hum.humd.err.log + +EOF + cat > "$darwin_unit_dir/humnest.plist" < + + + Labelhumnest + Program$HUMNEST_BIN + RunAtLoad + KeepAlive + StandardOutPath$HOME/Library/Logs/sh.hum.humnest.out.log + StandardErrorPath$HOME/Library/Logs/sh.hum.humnest.err.log + +EOF + local uid; uid="$(id -u)" + launchctl bootout "gui/$uid/humd" 2>/dev/null || true + launchctl bootout "gui/$uid/humnest" 2>/dev/null || true + launchctl bootstrap "gui/$uid" "$darwin_unit_dir/humd.plist" + launchctl bootstrap "gui/$uid" "$darwin_unit_dir/humnest.plist" } start_daemons() { - install_and_start humd - install_and_start humnest + case "$OS" in + Linux) write_linux_units ;; + Darwin) write_darwin_units ;; + *) warn "service install not supported on $OS — run '$HUMD_BIN' and '$HUMNEST_BIN' manually"; return 0 ;; + esac + log "humd + humnest registered + started ($OS)" sleep 1 } @@ -261,14 +312,16 @@ show_status() { echo "peers.json: $HUM_CONFIG/peers.json $([ -s "$HUM_CONFIG/peers.json" ] && echo ✓ || echo MISSING)" echo "hum.json: $HUM_CONFIG/hum.json $([ -s "$HUM_CONFIG/hum.json" ] && echo ✓ || echo MISSING)" echo "thrum socket: $THRUM_SOCK $([ -S "$THRUM_SOCK" ] && echo ✓ || echo absent)" - if [ -x "$HUMCTL_BIN" ]; then - echo "service (humd):" - "$HUMCTL_BIN" status humd 2>&1 | head -6 || true - echo "service (humnest):" - "$HUMCTL_BIN" status humnest 2>&1 | head -6 || true - else - echo "humctl not installed — cannot query service status" - fi + case "$OS" in + Linux) + systemctl --user status humd --no-pager 2>&1 | head -6 || true + systemctl --user status humnest --no-pager 2>&1 | head -6 || true + ;; + Darwin) + launchctl print "gui/$(id -u)/humd" 2>&1 | head -6 || true + launchctl print "gui/$(id -u)/humnest" 2>&1 | head -6 || true + ;; + esac } show_logs() { @@ -288,12 +341,20 @@ show_logs() { } uninstall() { - if [ -x "$HUMCTL_BIN" ]; then - "$HUMCTL_BIN" stop humnest 2>/dev/null || true - "$HUMCTL_BIN" stop humd 2>/dev/null || true - "$HUMCTL_BIN" uninstall humnest 2>/dev/null || true - "$HUMCTL_BIN" uninstall humd 2>/dev/null || true - fi + case "$OS" in + Linux) + systemctl --user stop humnest.service humd.service 2>/dev/null || true + systemctl --user disable humnest.service humd.service 2>/dev/null || true + rm -f "$linux_unit_dir/humd.service" "$linux_unit_dir/humnest.service" + systemctl --user daemon-reload 2>/dev/null || true + ;; + Darwin) + local uid; uid="$(id -u)" + launchctl bootout "gui/$uid/humnest" 2>/dev/null || true + launchctl bootout "gui/$uid/humd" 2>/dev/null || true + rm -f "$darwin_unit_dir/humnest.plist" "$darwin_unit_dir/humd.plist" + ;; + esac rm -f "$HUMD_BIN" "$HUMNEST_BIN" "$HUM_CLI_BIN" "$HUMCTL_BIN" log "uninstalled. State preserved in $HUM_STATE + $HUM_CONFIG." log " run \`./install purge\` to remove state too." From bf3076e896a66dd85c195f1bfea516c3cefa6264 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 20:07:54 +0000 Subject: [PATCH 09/18] adopt orchd as bee supervisor; humnest crate deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit humnest is gone. orchd (sibling Rust project; user-scope systemd + launchd platform contributed upstream this round) replaces it. Architecture now: launchd/systemd └── humd (one user service; humctl operates; ./install registers) orchd └── per-bee user units (one each, generated from per-hive Orchfile) Hive surface: each hive ships an Orchfile at its root. `hum hive install ` resolves the target, builds (cargo install --path), copies the Orchfile into ~/.config/hum/orch.d/, re-assembles ~/.config/hum/Orchfile, and runs `orchd up `. No more per-hive install bash scripts. What dies: - humnest crate (entire thing — 4 modules, supervisor + control + log + lib) - hum_paths::humnest_sock, humnest_runtime, HumnestRuntimeInfo - config::HumnestSection, BeeConfig (orch's Orchfile owns this declaration) - hum.schema.json humnest section - 4 hives/*/install bash scripts (claude-cli, claude-repl, humfs, paid-oracle) - ./install humnest unit generation (no second user unit) What lives: - humd, humctl, hum CLI: unchanged in role - humctl: humd operator only (start/stop/status/logs/health) - orch + orchd: pulled as git deps by ./install via cargo install What's new: - hives/{claude-cli,claude-repl,humfs,paid-oracle}/Orchfile (declarative) - hum CLI: hive_install does build + register-via-Orchfile + orchd up - hum CLI: bee enter/exit/reenter routes through orchd up/down/restart - hum CLI: 'hum nest' delegates to 'orchd status' - ./install pulls orch + orchd from github via cargo install openai-server (TS) still has its bash install — TS build pipeline (pnpm install + tsup) not yet automated through hum hive install. Follow-up. 247 tests pass. --- Cargo.lock | 22 ---- Cargo.toml | 1 - config/src/lib.rs | 28 ---- hives/claude-cli/Orchfile | 3 + hives/claude-cli/install | 115 ---------------- hives/claude-repl/Orchfile | 3 + hives/claude-repl/install | 99 -------------- hives/humfs/Orchfile | 3 + hives/humfs/install | 99 -------------- hives/paid-oracle/Orchfile | 3 + hives/paid-oracle/install | 144 -------------------- hum-paths/src/lib.rs | 34 ----- hum.schema.json | 25 ---- hum/src/main.rs | 207 +++++++++++++++-------------- humctl/src/main.rs | 4 +- humnest/Cargo.toml | 33 ----- humnest/src/control.rs | 91 ------------- humnest/src/lib.rs | 32 ----- humnest/src/log_capture.rs | 52 -------- humnest/src/main.rs | 22 ---- humnest/src/supervisor.rs | 187 -------------------------- install | 261 +++++++++++-------------------------- 22 files changed, 198 insertions(+), 1270 deletions(-) create mode 100644 hives/claude-cli/Orchfile delete mode 100755 hives/claude-cli/install create mode 100644 hives/claude-repl/Orchfile delete mode 100755 hives/claude-repl/install create mode 100644 hives/humfs/Orchfile delete mode 100755 hives/humfs/install create mode 100644 hives/paid-oracle/Orchfile delete mode 100755 hives/paid-oracle/install delete mode 100644 humnest/Cargo.toml delete mode 100644 humnest/src/control.rs delete mode 100644 humnest/src/lib.rs delete mode 100644 humnest/src/log_capture.rs delete mode 100644 humnest/src/main.rs delete mode 100644 humnest/src/supervisor.rs diff --git a/Cargo.lock b/Cargo.lock index 2a7a4170..523a9990 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1990,28 +1990,6 @@ dependencies = [ "tree-sitter-typescript", ] -[[package]] -name = "humnest" -version = "0.31.18" -dependencies = [ - "anyhow", - "command-group", - "config", - "hum-paths", - "metrics", - "nest", - "parking_lot", - "serde", - "serde_json", - "tempfile", - "thrum-core", - "thrumd", - "tokio", - "tokio-util", - "tracing", - "tracing-subscriber", -] - [[package]] name = "hums" version = "0.31.18" diff --git a/Cargo.toml b/Cargo.toml index 9114f3ca..cf261c07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ members = [ "codegen", "drift", "humd", - "humnest", "humctl", "hum", "mcp", diff --git a/config/src/lib.rs b/config/src/lib.rs index aa411b75..2cc53037 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -133,29 +133,6 @@ impl Default for NestSection { } } -// ── humnest ─────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct HumnestSection { - #[serde(default)] - pub bees: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BeeConfig { - pub kind: String, - #[serde(default)] - pub argv: Vec, - #[serde(default)] - pub env: std::collections::HashMap, - #[serde(default = "defaults::restart")] - pub restart: String, - #[serde(default = "defaults::max_retries", rename = "maxRetries")] - pub max_retries: u32, - #[serde(default = "defaults::backoff_ms", rename = "backoffMs")] - pub backoff_ms: u64, -} - // ── top-level ───────────────────────────────────────────────────────────── /// Daemon-scoped policy: `humd` knobs, `fs` grounding, and `nest` @@ -169,8 +146,6 @@ pub struct HumConfig { pub fs: FsSection, #[serde(default)] pub nest: NestSection, - #[serde(default)] - pub humnest: HumnestSection, } // ── path resolution ─────────────────────────────────────────────────────── @@ -312,9 +287,6 @@ mod defaults { pub fn default_hive() -> String { "claude-repl".into() } - pub fn restart() -> String { "always".into() } - pub fn max_retries() -> u32 { 10 } - pub fn backoff_ms() -> u64 { 1_000 } pub fn denied() -> Vec { [ "~/.ssh", diff --git a/hives/claude-cli/Orchfile b/hives/claude-cli/Orchfile new file mode 100644 index 00000000..dc385ad3 --- /dev/null +++ b/hives/claude-cli/Orchfile @@ -0,0 +1,3 @@ +SERVICE claude-cli +RUN ${HOME}/.local/bin/claude-cli-worker +RESTART always diff --git a/hives/claude-cli/install b/hives/claude-cli/install deleted file mode 100755 index 70897793..00000000 --- a/hives/claude-cli/install +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# claude-cli worker-bee installer — builds the standalone binary and -# registers it with humnest by appending an entry to hum.json -# humnest.bees, then bouncing humnest so the new child is picked up. -# -# No per-bee systemd / launchd unit: humnest is the supervisor now. -# -# Usage: -# hives/claude-cli/install # build + register with humnest -# hives/claude-cli/install uninstall # remove from humnest.bees + restart -# -# Env: -# HUM_THRUM_SOCK default $XDG_RUNTIME_DIR/hum/thrum.sock -# CLAUDE_CLI_PATH default $(command -v claude) -# CLAUDE_MODELS comma-separated model ids the worker advertises -# (default — claude-opus-4-7, sonnet-4-6, haiku-4-5, -# sonnet-4-5, haiku-4-5-20251001) -set -euo pipefail - -XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" -HUM_CONFIG="$XDG_CONFIG_HOME/hum" -HUM_JSON="$HUM_CONFIG/hum.json" -HIVE_DIR="$(cd "$(dirname "$0")" && pwd)" -KIND="claude-cli" -WORKER_BIN="$HOME/.local/bin/${KIND}-worker" -HUMCTL_BIN="$HOME/.local/bin/humctl" - -if [ -n "${XDG_RUNTIME_DIR:-}" ]; then - DEFAULT_SOCK="$XDG_RUNTIME_DIR/hum/thrum.sock" -else - DEFAULT_SOCK="/tmp/hum-$(id -u)/thrum.sock" -fi -THRUM_SOCK="${HUM_THRUM_SOCK:-$DEFAULT_SOCK}" -CLAUDE_BIN="${CLAUDE_CLI_PATH:-$(command -v claude || echo claude)}" -CLAUDE_MODELS="${CLAUDE_MODELS:-claude-opus-4-7,claude-sonnet-4-6,claude-haiku-4-5,claude-sonnet-4-5,claude-haiku-4-5-20251001}" - -log() { printf '\033[1m[%s-worker]\033[0m %s\n' "$KIND" "$*"; } -warn() { printf '\033[1;33m[%s-worker]\033[0m %s\n' "$KIND" "$*" >&2; } -fail() { printf '\033[1;31m[%s-worker]\033[0m %s\n' "$KIND" "$*" >&2; exit 1; } - -build() { - if command -v cargo >/dev/null 2>&1; then - log "building $KIND-worker (cargo install --path $HIVE_DIR)" - cargo install --quiet --locked --path "$HIVE_DIR" --root "$HOME/.local" --force --bin "${KIND}-worker" - [ -x "$WORKER_BIN" ] || fail "binary not at $WORKER_BIN after build" - log "built: $WORKER_BIN" - elif [ -x "$WORKER_BIN" ]; then - log "cargo unavailable; using pre-built $WORKER_BIN" - else - fail "no $WORKER_BIN and cargo unavailable — install rustup or have an upstream deploy supply the binary" - fi -} - -# Append (or replace) the claude-cli bee in hum.json humnest.bees. -register_with_humnest() { - command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" - [ -s "$HUM_JSON" ] || fail "$HUM_JSON missing — run the top-level ./install first" - - local bee_json - bee_json=$(jq -n \ - --arg kind "$KIND" \ - --arg program "$WORKER_BIN" \ - --arg home "$HOME" \ - --arg log_level "trace,penny=error" \ - --arg runtime "${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --arg sock "$THRUM_SOCK" \ - --arg claude_bin "$CLAUDE_BIN" \ - --arg claude_models "$CLAUDE_MODELS" \ - --arg path "$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" \ - '{kind: $kind, argv: [$program], env: { - HOME: $home, - HUM_LOG_LEVEL: $log_level, - XDG_RUNTIME_DIR: $runtime, - HUM_THRUM_SOCK: $sock, - CLAUDE_CLI_PATH: $claude_bin, - CLAUDE_MODELS: $claude_models, - PATH: $path - }}') - - local tmp; tmp=$(mktemp) - jq --argjson bee "$bee_json" ' - .humnest //= {bees: []} - | .humnest.bees //= [] - | .humnest.bees |= (map(select(.kind != $bee.kind)) + [$bee]) - ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" - log "registered $KIND in humnest.bees" -} - -bounce_humnest() { - [ -x "$HUMCTL_BIN" ] || { warn "humctl not at $HUMCTL_BIN — start humnest manually to pick up new bee"; return 0; } - "$HUMCTL_BIN" restart humnest 2>/dev/null \ - || "$HUMCTL_BIN" start humnest 2>/dev/null \ - || warn "could not restart humnest via humctl" - log "humnest bounced — $KIND should spawn on its next tick" - log " models advertised: $CLAUDE_MODELS" -} - -uninstall() { - command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" - if [ -s "$HUM_JSON" ]; then - local tmp; tmp=$(mktemp) - jq --arg kind "$KIND" ' - .humnest.bees |= (. // [] | map(select(.kind != $kind))) - ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" - log "removed $KIND from humnest.bees" - fi - [ -x "$HUMCTL_BIN" ] && "$HUMCTL_BIN" restart humnest 2>/dev/null || true - log "uninstalled." -} - -case "${1:-install}" in - install|"") build && register_with_humnest && bounce_humnest ;; - uninstall) uninstall ;; - *) fail "unknown command: $1 (try: install, uninstall)" ;; -esac diff --git a/hives/claude-repl/Orchfile b/hives/claude-repl/Orchfile new file mode 100644 index 00000000..1d389d8f --- /dev/null +++ b/hives/claude-repl/Orchfile @@ -0,0 +1,3 @@ +SERVICE claude-repl +RUN ${HOME}/.local/bin/claude-repl-worker +RESTART always diff --git a/hives/claude-repl/install b/hives/claude-repl/install deleted file mode 100755 index fbcf1016..00000000 --- a/hives/claude-repl/install +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -# claude-repl worker-bee installer — same shape as claude-cli's. Builds -# the standalone binary and registers it with humnest via hum.json -# humnest.bees, then bounces humnest. No per-bee unit. -set -euo pipefail - -XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" -HUM_JSON="$XDG_CONFIG_HOME/hum/hum.json" -HIVE_DIR="$(cd "$(dirname "$0")" && pwd)" -KIND="claude-repl" -WORKER_BIN="$HOME/.local/bin/${KIND}-worker" -HUMCTL_BIN="$HOME/.local/bin/humctl" - -if [ -n "${XDG_RUNTIME_DIR:-}" ]; then - DEFAULT_SOCK="$XDG_RUNTIME_DIR/hum/thrum.sock" -else - DEFAULT_SOCK="/tmp/hum-$(id -u)/thrum.sock" -fi -THRUM_SOCK="${HUM_THRUM_SOCK:-$DEFAULT_SOCK}" -CLAUDE_BIN="${CLAUDE_CLI_PATH:-$(command -v claude || echo claude)}" -CLAUDE_MODELS="${CLAUDE_MODELS:-claude-opus-4-7,claude-sonnet-4-6,claude-haiku-4-5}" - -log() { printf '\033[1m[%s-worker]\033[0m %s\n' "$KIND" "$*"; } -warn() { printf '\033[1;33m[%s-worker]\033[0m %s\n' "$KIND" "$*" >&2; } -fail() { printf '\033[1;31m[%s-worker]\033[0m %s\n' "$KIND" "$*" >&2; exit 1; } - -build() { - if command -v cargo >/dev/null 2>&1; then - log "building $KIND-worker (cargo install --path $HIVE_DIR)" - cargo install --quiet --locked --path "$HIVE_DIR" --root "$HOME/.local" --force --bin "${KIND}-worker" - [ -x "$WORKER_BIN" ] || fail "binary not at $WORKER_BIN after build" - log "built: $WORKER_BIN" - elif [ -x "$WORKER_BIN" ]; then - log "cargo unavailable; using pre-built $WORKER_BIN" - else - fail "no $WORKER_BIN and cargo unavailable — install rustup or have an upstream deploy supply the binary" - fi -} - -register_with_humnest() { - command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" - [ -s "$HUM_JSON" ] || fail "$HUM_JSON missing — run the top-level ./install first" - - local bee_json - bee_json=$(jq -n \ - --arg kind "$KIND" \ - --arg program "$WORKER_BIN" \ - --arg home "$HOME" \ - --arg log_level "trace,penny=error" \ - --arg runtime "${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --arg sock "$THRUM_SOCK" \ - --arg claude_bin "$CLAUDE_BIN" \ - --arg claude_models "$CLAUDE_MODELS" \ - --arg path "$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" \ - '{kind: $kind, argv: [$program], env: { - HOME: $home, - HUM_LOG_LEVEL: $log_level, - XDG_RUNTIME_DIR: $runtime, - HUM_THRUM_SOCK: $sock, - CLAUDE_CLI_PATH: $claude_bin, - CLAUDE_MODELS: $claude_models, - PATH: $path - }}') - - local tmp; tmp=$(mktemp) - jq --argjson bee "$bee_json" ' - .humnest //= {bees: []} - | .humnest.bees //= [] - | .humnest.bees |= (map(select(.kind != $bee.kind)) + [$bee]) - ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" - log "registered $KIND in humnest.bees" -} - -bounce_humnest() { - [ -x "$HUMCTL_BIN" ] || { warn "humctl not at $HUMCTL_BIN — start humnest manually"; return 0; } - "$HUMCTL_BIN" restart humnest 2>/dev/null \ - || "$HUMCTL_BIN" start humnest 2>/dev/null \ - || warn "could not restart humnest via humctl" - log "humnest bounced — $KIND should spawn on its next tick" -} - -uninstall() { - command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" - if [ -s "$HUM_JSON" ]; then - local tmp; tmp=$(mktemp) - jq --arg kind "$KIND" ' - .humnest.bees |= (. // [] | map(select(.kind != $kind))) - ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" - log "removed $KIND from humnest.bees" - fi - [ -x "$HUMCTL_BIN" ] && "$HUMCTL_BIN" restart humnest 2>/dev/null || true - log "uninstalled." -} - -case "${1:-install}" in - install|"") build && register_with_humnest && bounce_humnest ;; - uninstall) uninstall ;; - *) fail "unknown command: $1 (try: install, uninstall)" ;; -esac diff --git a/hives/humfs/Orchfile b/hives/humfs/Orchfile new file mode 100644 index 00000000..bac99993 --- /dev/null +++ b/hives/humfs/Orchfile @@ -0,0 +1,3 @@ +SERVICE humfs +RUN ${HOME}/.local/bin/humfs-forager +RESTART always diff --git a/hives/humfs/install b/hives/humfs/install deleted file mode 100755 index d6ac5da4..00000000 --- a/hives/humfs/install +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -# humfs forager-bee installer — builds the standalone humfs-forager -# binary and registers it with humnest via hum.json humnest.bees. -# humnest supervises the child; the forager registers with humd via -# thrum bee:["forager"] once it's running. -# -# Usage: -# hives/humfs/install # build + register -# hives/humfs/install uninstall # remove from humnest.bees -# -# Env: -# HUM_THRUM_SOCK default $XDG_RUNTIME_DIR/hum/thrum.sock -set -euo pipefail - -XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" -HUM_JSON="$XDG_CONFIG_HOME/hum/hum.json" -HIVE_DIR="$(cd "$(dirname "$0")" && pwd)" -KIND="humfs" -FORAGER_BIN="$HOME/.local/bin/${KIND}-forager" -HUMCTL_BIN="$HOME/.local/bin/humctl" - -if [ -n "${XDG_RUNTIME_DIR:-}" ]; then - DEFAULT_SOCK="$XDG_RUNTIME_DIR/hum/thrum.sock" -else - DEFAULT_SOCK="/tmp/hum-$(id -u)/thrum.sock" -fi -THRUM_SOCK="${HUM_THRUM_SOCK:-$DEFAULT_SOCK}" - -log() { printf '\033[1m[%s-forager]\033[0m %s\n' "$KIND" "$*"; } -warn() { printf '\033[1;33m[%s-forager]\033[0m %s\n' "$KIND" "$*" >&2; } -fail() { printf '\033[1;31m[%s-forager]\033[0m %s\n' "$KIND" "$*" >&2; exit 1; } - -build() { - if command -v cargo >/dev/null 2>&1; then - log "building $KIND-forager (cargo install --path $HIVE_DIR)" - cargo install --quiet --locked --path "$HIVE_DIR" --root "$HOME/.local" --force --bin "${KIND}-forager" - [ -x "$FORAGER_BIN" ] || fail "binary not at $FORAGER_BIN after build" - log "built: $FORAGER_BIN" - elif [ -x "$FORAGER_BIN" ]; then - log "cargo unavailable; using pre-built $FORAGER_BIN" - else - fail "no $FORAGER_BIN and cargo unavailable — install rustup or have an upstream deploy supply the binary" - fi -} - -register_with_humnest() { - command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" - [ -s "$HUM_JSON" ] || fail "$HUM_JSON missing — run the top-level ./install first" - - local bee_json - bee_json=$(jq -n \ - --arg kind "$KIND" \ - --arg program "$FORAGER_BIN" \ - --arg log_level "trace,penny=error" \ - --arg runtime "${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --arg sock "$THRUM_SOCK" \ - --arg path "$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" \ - '{kind: $kind, argv: [$program], env: { - HUM_LOG_LEVEL: $log_level, - XDG_RUNTIME_DIR: $runtime, - HUM_THRUM_SOCK: $sock, - PATH: $path - }}') - - local tmp; tmp=$(mktemp) - jq --argjson bee "$bee_json" ' - .humnest //= {bees: []} - | .humnest.bees //= [] - | .humnest.bees |= (map(select(.kind != $bee.kind)) + [$bee]) - ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" - log "registered $KIND in humnest.bees" -} - -bounce_humnest() { - [ -x "$HUMCTL_BIN" ] || { warn "humctl not at $HUMCTL_BIN — start humnest manually"; return 0; } - "$HUMCTL_BIN" restart humnest 2>/dev/null \ - || "$HUMCTL_BIN" start humnest 2>/dev/null \ - || warn "could not restart humnest via humctl" - log "humnest bounced — $KIND should spawn on its next tick" -} - -uninstall() { - command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" - if [ -s "$HUM_JSON" ]; then - local tmp; tmp=$(mktemp) - jq --arg kind "$KIND" ' - .humnest.bees |= (. // [] | map(select(.kind != $kind))) - ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" - log "removed $KIND from humnest.bees" - fi - [ -x "$HUMCTL_BIN" ] && "$HUMCTL_BIN" restart humnest 2>/dev/null || true - log "uninstalled." -} - -case "${1:-install}" in - install|"") build && register_with_humnest && bounce_humnest ;; - uninstall) uninstall ;; - *) fail "unknown command: $1 (try: install, uninstall)" ;; -esac diff --git a/hives/paid-oracle/Orchfile b/hives/paid-oracle/Orchfile new file mode 100644 index 00000000..ce2fe87b --- /dev/null +++ b/hives/paid-oracle/Orchfile @@ -0,0 +1,3 @@ +SERVICE paid-oracle +RUN ${HOME}/.local/bin/paid-oracle +RESTART always diff --git a/hives/paid-oracle/install b/hives/paid-oracle/install deleted file mode 100755 index 0b8bfcc9..00000000 --- a/hives/paid-oracle/install +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env bash -# paid-oracle forager-bee installer. Builds the binary and registers it -# with humnest via hum.json humnest.bees. humnest supervises it; the -# bee registers with humd via bee:["forager"], hive:"paid-oracle", -# provides:["x402:quote"]. First call returns chi:"error" 402 with -# terms, asker pays USDC on the configured chain and resubmits with -# paymentProof. -# -# Usage: -# hives/paid-oracle/install # build + register -# hives/paid-oracle/install uninstall # remove from humnest.bees -# -# Required env (or read from ~/.config/hum/paid-oracle/keys.json -# receiver.address if present): -# PAID_ORACLE_PAY_TO EVM address that receives USDC -# -# Chain defaults to Base Sepolia (testnet, no real money). Override: -# PAID_ORACLE_CHAIN base-sepolia | base-mainnet | arc | … -# PAID_ORACLE_RPC JSON-RPC endpoint -# PAID_ORACLE_USDC USDC contract address (or zero for native) -# PAID_ORACLE_DECIMALS 6 (Base/Eth) or 18 (Arc) -# PAID_ORACLE_PAY_KIND erc20 | native -set -euo pipefail - -XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" -HUM_JSON="$XDG_CONFIG_HOME/hum/hum.json" -HIVE_DIR="$(cd "$(dirname "$0")" && pwd)" -KIND="paid-oracle" -BIN="$HOME/.local/bin/${KIND}" -HUMCTL_BIN="$HOME/.local/bin/humctl" - -if [ -n "${XDG_RUNTIME_DIR:-}" ]; then - DEFAULT_SOCK="$XDG_RUNTIME_DIR/hum/thrum.sock" -else - DEFAULT_SOCK="/tmp/hum-$(id -u)/thrum.sock" -fi -THRUM_SOCK="${HUM_THRUM_SOCK:-$DEFAULT_SOCK}" - -KEYS_PATH="$XDG_CONFIG_HOME/hum/paid-oracle/keys.json" - -# Derive PAY_TO from generated keys if env not set. jq if available; -# else a tr-based fallback so installs don't depend on jq for derivation -# (jq is still required for the humnest registration step below). -if [ -z "${PAID_ORACLE_PAY_TO:-}" ] && [ -f "$KEYS_PATH" ]; then - if command -v jq >/dev/null 2>&1; then - PAID_ORACLE_PAY_TO=$(jq -r '.receiver.address // empty' "$KEYS_PATH" || true) - else - PAID_ORACLE_PAY_TO=$(tr -d '\n' < "$KEYS_PATH" | grep -oE '"receiver"[^{]*\{[^}]*"address"[[:space:]]*:[[:space:]]*"0x[a-fA-F0-9]+"' | grep -oE '0x[a-fA-F0-9]+' | head -1 || true) - fi -fi - -: "${PAID_ORACLE_CHAIN:=base-sepolia}" -: "${PAID_ORACLE_RPC:=https://sepolia.base.org}" -: "${PAID_ORACLE_USDC:=0x036CbD53842c5426634e7929541eC2318f3dCF7e}" -: "${PAID_ORACLE_DECIMALS:=6}" -: "${PAID_ORACLE_PAY_KIND:=erc20}" - -log() { printf '\033[1m[%s]\033[0m %s\n' "$KIND" "$*"; } -warn() { printf '\033[1;33m[%s]\033[0m %s\n' "$KIND" "$*" >&2; } -fail() { printf '\033[1;31m[%s]\033[0m %s\n' "$KIND" "$*" >&2; exit 1; } - -build() { - if command -v cargo >/dev/null 2>&1; then - log "building $KIND (cargo install --path $HIVE_DIR)" - cargo install --quiet --locked --path "$HIVE_DIR" --root "$HOME/.local" --force --bin "$KIND" - [ -x "$BIN" ] || fail "binary not at $BIN after build" - log "built: $BIN" - elif [ -x "$BIN" ]; then - log "cargo unavailable; using pre-built $BIN" - else - fail "no $BIN and cargo unavailable — install rustup or pre-deploy the binary" - fi -} - -register_with_humnest() { - command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" - [ -s "$HUM_JSON" ] || fail "$HUM_JSON missing — run the top-level ./install first" - if [ -z "${PAID_ORACLE_PAY_TO:-}" ]; then - fail "PAID_ORACLE_PAY_TO not set and no receiver.address in $KEYS_PATH; run hives/paid-oracle/buyer/keygen.ts or export PAID_ORACLE_PAY_TO." - fi - log "chain=$PAID_ORACLE_CHAIN pay_to=$PAID_ORACLE_PAY_TO usdc=$PAID_ORACLE_USDC" - - local bee_json - bee_json=$(jq -n \ - --arg kind "$KIND" \ - --arg program "$BIN" \ - --arg log_level "trace,penny=error" \ - --arg runtime "${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --arg sock "$THRUM_SOCK" \ - --arg pay_to "$PAID_ORACLE_PAY_TO" \ - --arg chain "$PAID_ORACLE_CHAIN" \ - --arg rpc "$PAID_ORACLE_RPC" \ - --arg usdc "$PAID_ORACLE_USDC" \ - --arg decimals "$PAID_ORACLE_DECIMALS" \ - --arg pay_kind "$PAID_ORACLE_PAY_KIND" \ - --arg path "$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" \ - '{kind: $kind, argv: [$program], env: { - HUM_LOG_LEVEL: $log_level, - XDG_RUNTIME_DIR: $runtime, - HUM_THRUM_SOCK: $sock, - PAID_ORACLE_PAY_TO: $pay_to, - PAID_ORACLE_CHAIN: $chain, - PAID_ORACLE_RPC: $rpc, - PAID_ORACLE_USDC: $usdc, - PAID_ORACLE_DECIMALS: $decimals, - PAID_ORACLE_PAY_KIND: $pay_kind, - PATH: $path - }}') - - local tmp; tmp=$(mktemp) - jq --argjson bee "$bee_json" ' - .humnest //= {bees: []} - | .humnest.bees //= [] - | .humnest.bees |= (map(select(.kind != $bee.kind)) + [$bee]) - ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" - log "registered $KIND in humnest.bees" -} - -bounce_humnest() { - [ -x "$HUMCTL_BIN" ] || { warn "humctl not at $HUMCTL_BIN — start humnest manually"; return 0; } - "$HUMCTL_BIN" restart humnest 2>/dev/null \ - || "$HUMCTL_BIN" start humnest 2>/dev/null \ - || warn "could not restart humnest via humctl" - log "humnest bounced — $KIND should spawn on its next tick" -} - -uninstall() { - command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" - if [ -s "$HUM_JSON" ]; then - local tmp; tmp=$(mktemp) - jq --arg kind "$KIND" ' - .humnest.bees |= (. // [] | map(select(.kind != $kind))) - ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" - log "removed $KIND from humnest.bees" - fi - [ -x "$HUMCTL_BIN" ] && "$HUMCTL_BIN" restart humnest 2>/dev/null || true - log "uninstalled." -} - -case "${1:-install}" in - install|"") build && register_with_humnest && bounce_humnest ;; - uninstall) uninstall ;; - *) fail "unknown command: $1 (try: install, uninstall)" ;; -esac diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index baf8d30f..d2fa538f 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -102,9 +102,6 @@ pub fn bees_snapshot() -> PathBuf { state_dir().join("bees.json") } /// Rendezvous file: running daemon publishes its socket path, pid, and version here. pub fn runtime_info() -> PathBuf { state_dir().join("runtime.json") } -pub fn humnest_sock() -> PathBuf { state_dir().join("humnest.sock") } -pub fn humnest_runtime() -> PathBuf { state_dir().join("humnest_runtime.json") } - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeInfo { pub socket: PathBuf, @@ -139,37 +136,6 @@ impl RuntimeInfo { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HumnestRuntimeInfo { - pub socket: PathBuf, - pub pid: u32, - pub version: String, - pub bound_at_ms: u64, -} - -impl HumnestRuntimeInfo { - pub fn read() -> Option { - let raw = std::fs::read_to_string(humnest_runtime()).ok()?; - serde_json::from_str(&raw).ok() - } - - pub fn write(&self) -> std::io::Result<()> { - let path = humnest_runtime(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let tmp = path.with_extension("json.tmp"); - let body = serde_json::to_string_pretty(self) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - std::fs::write(&tmp, body)?; - std::fs::rename(tmp, path) - } - - pub fn remove() { - let _ = std::fs::remove_file(humnest_runtime()); - } -} - /// `hum.json` (daemon policy). pub fn hum_json() -> PathBuf { config_dir().join("hum.json") } diff --git a/hum.schema.json b/hum.schema.json index 9ef3f9e2..0ba98fd1 100644 --- a/hum.schema.json +++ b/hum.schema.json @@ -89,31 +89,6 @@ "description": "Hive name humd routes to when a chi:prompt doesn't pin one. A hive configures its own runtime (binary, models) via its service env, not here." } } - }, - - "humnest": { - "type": "object", - "additionalProperties": false, - "description": "Bee supervisor — humnest reads bees[] and spawns each.", - "properties": { - "bees": { - "type": "array", - "default": [], - "items": { - "type": "object", - "additionalProperties": false, - "required": ["kind"], - "properties": { - "kind": { "type": "string", "description": "Hive kind, e.g. 'claude-cli'." }, - "argv": { "type": "array", "items": { "type": "string" }, "default": [] }, - "env": { "type": "object", "additionalProperties": { "type": "string" }, "default": {} }, - "restart": { "type": "string", "enum": ["always", "on-failure", "never"], "default": "always" }, - "maxRetries": { "type": "integer", "minimum": 0, "default": 10 }, - "backoffMs": { "type": "integer", "minimum": 0, "default": 1000 } - } - } - } - } } } } diff --git a/hum/src/main.rs b/hum/src/main.rs index 46456187..73295804 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -77,7 +77,7 @@ enum Cmd { #[arg(long)] list: bool, }, - /// List humnest-supervised bees with status (kind, pid, state, restart_count). + /// List orchd-managed bees (delegates to `orchd status`). Nest, /// Show lifetime counters from penny.json Penny, @@ -366,9 +366,9 @@ fn doctor() -> Result<()> { Err(e) => println!(" thrum sock: {} ✗ {e}", sock.display()), } - match hum_paths::HumnestRuntimeInfo::read() { - Some(rt) => println!(" humnest: ✓ pid={} version={} sock={}", rt.pid, rt.version, rt.socket.display()), - None => println!(" humnest: ✗ MISSING (no humnest_runtime.json — humnest not running)"), + match Command::new("orchd").arg("--version").output() { + Ok(o) if o.status.success() => println!(" orchd: ✓ {}", String::from_utf8_lossy(&o.stdout).trim()), + _ => println!(" orchd: ✗ NOT FOUND in PATH (run ./install to build it)"), } // 4. The claude binary (worker's compute). @@ -553,50 +553,96 @@ fn hive_list() -> Result<()> { /// our own repo maps to the local checkout; a /// foreign repo is shallow-cloned to a cache. fn hive_install(reference: &str) -> Result<()> { - let install = resolve_hive_install(reference)?; - if !install.exists() { - anyhow::bail!("no install script at {}", install.display()); + let dir = resolve_hive_dir(reference)?; + let orchfile = dir.join("Orchfile"); + if !orchfile.exists() { + anyhow::bail!("no Orchfile at {}", orchfile.display()); + } + let kind = read_orchfile_service(&orchfile)? + .ok_or_else(|| anyhow::anyhow!("no SERVICE directive in {}", orchfile.display()))?; + + if dir.join("Cargo.toml").exists() { + println!("building {kind} (cargo install --path {} ...)", dir.display()); + let s = Command::new("cargo") + .args(["install", "--quiet", "--locked", "--path"]).arg(&dir) + .args(["--root"]).arg(home_local()) + .arg("--force") + .status()?; + if !s.success() { anyhow::bail!("cargo install failed for {}", dir.display()); } + } else if dir.join("package.json").exists() { + anyhow::bail!("TS hive install not yet automated; run `pnpm install && pnpm build` in {}", dir.display()); + } else { + anyhow::bail!("no Cargo.toml or package.json in {} — don't know how to build", dir.display()); } - println!("installing hive from {} ...", install.display()); - let ok = Command::new(&install).status().map(|s| s.success()).unwrap_or(false); - if ok { println!("✓ installed; see `hum bee --list`"); Ok(()) } - else { anyhow::bail!("installer failed: {}", install.display()) } + + let orch_d = hum_paths::config_dir().join("orch.d"); + std::fs::create_dir_all(&orch_d)?; + let dest = orch_d.join(format!("{kind}.orch")); + std::fs::copy(&orchfile, &dest)?; + println!("registered {kind} ({})", dest.display()); + + rewrite_hum_orchfile(&orch_d)?; + + let s = orchd_cmd().arg("up").arg(&kind).status() + .map_err(|e| anyhow::anyhow!("orchd not found: {e}"))?; + if !s.success() { anyhow::bail!("orchd up {kind} failed"); } + println!("✓ {kind} entered; see `hum bee --list`"); + Ok(()) +} + +fn read_orchfile_service(path: &Path) -> Result> { + let raw = std::fs::read_to_string(path)?; + Ok(raw.lines() + .filter_map(|l| l.trim().strip_prefix("SERVICE ").map(|s| s.trim().to_string())) + .next()) +} + +fn rewrite_hum_orchfile(orch_d: &Path) -> Result<()> { + let mut combined = String::new(); + let mut entries: Vec<_> = std::fs::read_dir(orch_d)?.flatten() + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("orch")) + .collect(); + entries.sort_by_key(|e| e.file_name()); + for e in entries { + let body = std::fs::read_to_string(e.path())?; + combined.push_str(&body); + if !combined.ends_with('\n') { combined.push('\n'); } + combined.push('\n'); + } + std::fs::write(hum_orchfile(), combined)?; + Ok(()) +} + +fn home_local() -> PathBuf { + std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")) + .join(".local") } -fn resolve_hive_install(reference: &str) -> Result { - // github tree URL — the form bees advertise in `source`. +fn resolve_hive_dir(reference: &str) -> Result { if let Some(rest) = reference.strip_prefix("https://github.com/") { - // //tree// let parts: Vec<&str> = rest.splitn(5, '/').collect(); if parts.len() == 5 && parts[2] == "tree" { let (org, repo, branch, sub) = (parts[0], parts[1], parts[3], parts[4]); - // Our own repo → use the local checkout, no network. if org == "adiled" && repo == "hum" { - return Ok(repo_root_or_install_dir().join(sub).join("install")); + return Ok(repo_root_or_install_dir().join(sub)); } - // Foreign repo → shallow clone into a cache, then the subpath. - let cache = hum_paths::cache_dir() - .join("hives").join(format!("{org}-{repo}-{branch}")); + let cache = hum_paths::cache_dir().join("hives").join(format!("{org}-{repo}-{branch}")); if !cache.exists() { std::fs::create_dir_all(cache.parent().unwrap()).ok(); let url = format!("https://github.com/{org}/{repo}"); println!("cloning {url} @ {branch} ..."); let ok = Command::new("git") .args(["clone", "--depth", "1", "--branch", branch, &url]) - .arg(&cache) - .status().map(|s| s.success()).unwrap_or(false); + .arg(&cache).status().map(|s| s.success()).unwrap_or(false); if !ok { anyhow::bail!("git clone failed: {url}"); } } - return Ok(cache.join(sub).join("install")); + return Ok(cache.join(sub)); } anyhow::bail!("unrecognized github source URL (want .../tree//): {reference}"); } - // Local path — dir holding an `install`, or a direct install file. let p = PathBuf::from(reference); - if p.is_dir() { return Ok(p.join("install")); } - if p.is_file() { return Ok(p); } - // Bundled name — /hives//install. - let bundled = repo_root_or_install_dir().join("hives").join(reference).join("install"); + if p.is_dir() { return Ok(p); } + let bundled = repo_root_or_install_dir().join("hives").join(reference); if bundled.exists() { return Ok(bundled); } anyhow::bail!("can't resolve hive '{reference}' (not a bundled name, path, or github source URL)"); } @@ -700,7 +746,7 @@ fn bee(target: Option, verb: Option, list: bool) -> Result<()> { // Prefer humnest for any kind it knows about; fall back to svc.sh for // legacy units or unknown targets (svc_active/svc_last_exit helpers // stay live so `hum bee --list` keeps working). - if target != "all" && humnest_route_verb(&target, &verb)? { + if target != "all" && orch_route_verb(&target, &verb)? { return Ok(()); } let op = match verb.as_str() { @@ -788,90 +834,55 @@ fn repo_root_or_install_dir() -> PathBuf { PathBuf::from(".") } -// ── humnest RPC ────────────────────────────────────────────────────────── +// ── orchd shell-outs (bee lifecycle) ───────────────────────────────────── -/// Single-shot NDJSON exchange with humnest over its unix socket. -fn humnest_rpc(tone: serde_json::Value) -> anyhow::Result { - use std::io::{Write, BufRead, BufReader}; - use std::os::unix::net::UnixStream; - use std::time::Duration; - let sock = hum_paths::humnest_sock(); - let mut s = UnixStream::connect(&sock) - .map_err(|e| anyhow::anyhow!("connect humnest at {}: {e}", sock.display()))?; - s.set_read_timeout(Some(Duration::from_secs(2)))?; - s.set_write_timeout(Some(Duration::from_secs(2)))?; - let line = format!("{}\n", tone); - s.write_all(line.as_bytes())?; - let mut reader = BufReader::new(s); - let mut buf = String::new(); - reader.read_line(&mut buf)?; - let reply: serde_json::Value = serde_json::from_str(buf.trim())?; - Ok(reply) -} - -/// Kinds humnest knows about (hum.json humnest.bees[].kind). -fn humnest_catalog() -> Vec { - let hum_json = hum_paths::hum_json(); - let Ok(raw) = std::fs::read_to_string(&hum_json) else { return Vec::new(); }; - let Ok(v) = serde_json::from_str::(&raw) else { return Vec::new(); }; - v.get("humnest").and_then(|h| h.get("bees")).and_then(|b| b.as_array()) - .map(|arr| arr.iter() - .filter_map(|b| b.get("kind").and_then(|k| k.as_str()).map(str::to_string)) - .collect()) - .unwrap_or_default() +fn hum_orchfile() -> PathBuf { hum_paths::config_dir().join("Orchfile") } + +fn orchd_cmd() -> Command { + let mut c = Command::new("orchd"); + c.arg("--orchfile").arg(hum_orchfile()) + .arg("--user") + .arg("--namespace").arg("hum"); + c +} + +/// Service names declared in hum's Orchfile. +fn orch_catalog() -> Vec { + let path = hum_orchfile(); + let Ok(raw) = std::fs::read_to_string(&path) else { return Vec::new(); }; + raw.lines() + .filter_map(|l| l.trim().strip_prefix("SERVICE ").map(|s| s.trim().to_string())) + .collect() } fn nest() -> Result<()> { - let reply = humnest_rpc(serde_json::json!({"chi":"humnest-list"}))?; - if reply.get("chi").and_then(|c| c.as_str()) == Some("humnest-err") { - let msg = reply.get("message").and_then(|m| m.as_str()).unwrap_or("unknown"); - anyhow::bail!("humnest: {msg}"); - } - let bees = reply.get("bees").and_then(|b| b.as_array()).cloned().unwrap_or_default(); - if bees.is_empty() { - println!("no bees in humnest (configure hum.json humnest.bees)."); - return Ok(()); - } - println!(" {:<18} {:<8} {:<12} {:<8} {}", "KIND", "PID", "STATE", "RESTARTS", "LAST_EXIT"); - for b in &bees { - let kind = b.get("kind").and_then(|x| x.as_str()).unwrap_or("?"); - let pid = b.get("pid").and_then(|x| x.as_u64()).map(|n| n.to_string()).unwrap_or_else(|| "—".into()); - let state = b.get("state").and_then(|x| x.as_str()).unwrap_or("?"); - let restarts = b.get("restart_count").and_then(|x| x.as_u64()).unwrap_or(0); - let last = b.get("last_exit_code").and_then(|x| x.as_i64()).map(|n| n.to_string()).unwrap_or_else(|| "—".into()); - println!(" {:<18} {:<8} {:<12} {:<8} {}", kind, pid, state, restarts, last); + let status = orchd_cmd().arg("status").status() + .map_err(|e| anyhow::anyhow!("orchd not found: {e}"))?; + if !status.success() { + anyhow::bail!("orchd status failed"); } Ok(()) } -/// Route enter/exit/reenter through humnest. Returns Ok(true) if humnest -/// handled the verb, Ok(false) if the kind is not in humnest's catalog. -fn humnest_route_verb(kind: &str, verb: &str) -> Result { - if !humnest_catalog().iter().any(|k| k == kind) { +/// Route enter/exit/reenter through orchd. Returns Ok(true) if orchd +/// handled the verb, Ok(false) if the kind is not in orchd's catalog. +fn orch_route_verb(kind: &str, verb: &str) -> Result { + if !orch_catalog().iter().any(|k| k == kind) { return Ok(false); } - let tones: Vec = match verb { - "enter" => vec![serde_json::json!({"chi":"humnest-spawn","kind":kind})], - "exit" => vec![serde_json::json!({"chi":"humnest-kill","kind":kind})], - "reenter" => vec![ - serde_json::json!({"chi":"humnest-kill","kind":kind}), - serde_json::json!({"chi":"humnest-spawn","kind":kind}), - ], + let verb_arg = match verb { + "enter" => "up", + "exit" => "down", + "reenter" => "restart", other => anyhow::bail!("unknown verb '{other}' (enter | exit | reenter)"), }; let past = match verb { "enter" => "entered", "exit" => "exited", _ => "re-entered" }; - for tone in tones { - let reply = humnest_rpc(tone)?; - match reply.get("chi").and_then(|c| c.as_str()) { - Some("humnest-ok") => {} - Some("humnest-err") => { - let msg = reply.get("message").and_then(|m| m.as_str()).unwrap_or("unknown"); - anyhow::bail!("humnest: {msg}"); - } - other => anyhow::bail!("humnest: unexpected reply chi={:?}", other), - } + let status = orchd_cmd().arg(verb_arg).arg(kind).status() + .map_err(|e| anyhow::anyhow!("orchd not found: {e}"))?; + if !status.success() { + anyhow::bail!("orchd {verb_arg} {kind} failed"); } - println!(" ✓ {past} {kind} (humnest)"); + println!(" ✓ {past} {kind} (orchd)"); Ok(true) } diff --git a/humctl/src/main.rs b/humctl/src/main.rs index ea5add7c..9f19e389 100644 --- a/humctl/src/main.rs +++ b/humctl/src/main.rs @@ -1,6 +1,6 @@ //! humctl — humd operator. Bootstrap registers humd as a user service; humctl -//! drives it after that. humnest is opaque here; observe it through `hum nest` -//! and `hum bee info`. +//! drives it after that. Bee supervision lives in orchd; use `hum bee` / +//! `hum hive` to talk to it. use std::process::{Command, ExitCode}; diff --git a/humnest/Cargo.toml b/humnest/Cargo.toml deleted file mode 100644 index ebe5a947..00000000 --- a/humnest/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "humnest" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Bee supervisor daemon. Reads hum.json humnest.bees, spawns each as a supervised child (nest::lifecycle), restarts per policy. Sibling to humd." - -[[bin]] -name = "humnest" -path = "src/main.rs" - -[lib] -path = "src/lib.rs" - -[dependencies] -hum-paths = { path = "../hum-paths" } -config = { path = "../config" } -nest = { path = "../nest" } -thrum-core = { path = "../thrum-core" } -thrumd = { path = "../thrumd" } -tokio = { workspace = true } -tokio-util = "0.7" -command-group = { version = "5", features = ["with-tokio"] } -parking_lot = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -anyhow = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -metrics = "0.23" - -[dev-dependencies] -tempfile = "3" diff --git a/humnest/src/control.rs b/humnest/src/control.rs deleted file mode 100644 index d21532b5..00000000 --- a/humnest/src/control.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::Result; -use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::{UnixListener, UnixStream}; -use tokio::task::JoinHandle; -use tracing::{info, trace, warn}; - -use crate::supervisor::Supervisor; - -pub async fn serve(path: PathBuf, supervisor: Arc) -> Result> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - if path.exists() { - std::fs::remove_file(&path).ok(); - } - let listener = UnixListener::bind(&path)?; - info!(path = %path.display(), "humnest.control.listening"); - - let info = hum_paths::HumnestRuntimeInfo { - socket: path.clone(), - pid: std::process::id(), - version: env!("CARGO_PKG_VERSION").to_string(), - bound_at_ms: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64).unwrap_or(0), - }; - if let Err(e) = info.write() { - warn!(err = %e, "humnest.runtime.write_failed"); - } - - let handle = tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((sock, _)) => { - let sup = supervisor.clone(); - tokio::spawn(async move { handle_conn(sock, sup).await; }); - } - Err(e) => warn!(err = %e, "humnest.control.accept_failed"), - } - } - }); - Ok(handle) -} - -async fn handle_conn(stream: UnixStream, supervisor: Arc) { - let (r, mut w) = stream.into_split(); - let mut reader = BufReader::new(r).lines(); - while let Ok(Some(line)) = reader.next_line().await { - if line.is_empty() { continue; } - let tone: Value = match serde_json::from_str(&line) { - Ok(v) => v, - Err(e) => { trace!(err = %e, "humnest.parse.skip"); continue; } - }; - let chi = tone.get("chi").and_then(|c| c.as_str()).unwrap_or(""); - let reply = match chi { - "humnest-spawn" => { - let kind = tone.get("kind").and_then(|k| k.as_str()).unwrap_or(""); - spawn_by_kind(&supervisor, kind).await - } - "humnest-kill" => { - let kind = tone.get("kind").and_then(|k| k.as_str()).unwrap_or(""); - match supervisor.kill_one(kind).await { - Ok(_) => json!({"chi":"humnest-ok"}), - Err(e) => json!({"chi":"humnest-err","message":e.to_string()}), - } - } - "humnest-list" => { - let bees = supervisor.list(); - json!({"chi":"humnest-list","bees":bees}) - } - other => json!({"chi":"humnest-err","message":format!("unknown chi: {other}")}), - }; - let line = format!("{}\n", reply); - if w.write_all(line.as_bytes()).await.is_err() { break; } - } -} - -async fn spawn_by_kind(supervisor: &Arc, kind: &str) -> Value { - let cfg = config::load(); - match cfg.humnest.bees.iter().find(|b| b.kind == kind).cloned() { - Some(bc) => match supervisor.clone().spawn_one(bc).await { - Ok(_) => json!({"chi":"humnest-ok"}), - Err(e) => json!({"chi":"humnest-err","message":e.to_string()}), - } - None => json!({"chi":"humnest-err","message":format!("no bee of kind '{kind}' in hum.json")}), - } -} diff --git a/humnest/src/lib.rs b/humnest/src/lib.rs deleted file mode 100644 index fc73738b..00000000 --- a/humnest/src/lib.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! humnest — bee supervisor. - -pub mod supervisor; -pub mod control; -pub mod log_capture; - -pub use supervisor::{Supervisor, BeeStatus}; - -use std::sync::Arc; - -use anyhow::Result; -use tracing::info; - -pub async fn run(shutdown: F) -> Result<()> -where F: std::future::Future + Send, -{ - hum_paths::init(); - let cfg = config::load(); - info!(count = cfg.humnest.bees.len(), "humnest.boot"); - - let supervisor = Arc::new(Supervisor::new()); - supervisor.clone().spawn_all(cfg.humnest.bees).await; - - let socket = hum_paths::humnest_sock(); - let _ctl = control::serve(socket, supervisor.clone()).await?; - - shutdown.await; - info!("humnest.shutdown"); - supervisor.kill_all().await; - hum_paths::HumnestRuntimeInfo::remove(); - Ok(()) -} diff --git a/humnest/src/log_capture.rs b/humnest/src/log_capture.rs deleted file mode 100644 index cd139596..00000000 --- a/humnest/src/log_capture.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::path::PathBuf; - -use tokio::fs::OpenOptions; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{ChildStdout, ChildStderr}; -use tracing::warn; - -fn logs_dir() -> PathBuf { - hum_paths::state_dir().join("logs") -} - -pub fn out_log_path(kind: &str) -> PathBuf { - logs_dir().join(format!("{kind}.out.log")) -} - -pub fn err_log_path(kind: &str) -> PathBuf { - logs_dir().join(format!("{kind}.err.log")) -} - -/// Tail child stdout into the per-bee out log file. Spawns a task that -/// runs until the stream ends. -pub fn pipe_stdout(kind: String, stream: ChildStdout) { - let path = out_log_path(&kind); - tokio::spawn(async move { - if let Err(e) = pipe(stream, path).await { - warn!(%kind, err = %e, "humnest.log.stdout_failed"); - } - }); -} - -pub fn pipe_stderr(kind: String, stream: ChildStderr) { - let path = err_log_path(&kind); - tokio::spawn(async move { - if let Err(e) = pipe(stream, path).await { - warn!(%kind, err = %e, "humnest.log.stderr_failed"); - } - }); -} - -async fn pipe(stream: R, path: PathBuf) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - let mut file = OpenOptions::new().create(true).append(true).open(&path).await?; - let mut lines = BufReader::new(stream).lines(); - while let Ok(Some(line)) = lines.next_line().await { - let buf = format!("{line}\n"); - if file.write_all(buf.as_bytes()).await.is_err() { break; } - } - file.flush().await.ok(); - Ok(()) -} diff --git a/humnest/src/main.rs b/humnest/src/main.rs deleted file mode 100644 index 38d2adf4..00000000 --- a/humnest/src/main.rs +++ /dev/null @@ -1,22 +0,0 @@ -use anyhow::Result; -use tokio::signal::unix::{signal, SignalKind}; -use tracing::trace; -use tracing_subscriber::EnvFilter; - -#[tokio::main] -async fn main() -> Result<()> { - let filter = EnvFilter::try_from_env("HUM_LOG_LEVEL") - .unwrap_or_else(|_| EnvFilter::new("info")); - tracing_subscriber::fmt().with_env_filter(filter).with_target(false).compact().init(); - humnest::run(wait_for_shutdown()).await -} - -async fn wait_for_shutdown() { - let mut term = signal(SignalKind::terminate()).expect("SIGTERM"); - let mut int = signal(SignalKind::interrupt()).expect("SIGINT"); - tokio::select! { - _ = tokio::signal::ctrl_c() => trace!("humnest.shutdown.ctrl-c"), - _ = term.recv() => trace!("humnest.shutdown.sigterm"), - _ = int.recv() => trace!("humnest.shutdown.sigint"), - } -} diff --git a/humnest/src/supervisor.rs b/humnest/src/supervisor.rs deleted file mode 100644 index c473e124..00000000 --- a/humnest/src/supervisor.rs +++ /dev/null @@ -1,187 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Result; -use command_group::AsyncCommandGroup; -use parking_lot::RwLock; -use tokio::process::Command; -use tokio_util::sync::CancellationToken; -use tracing::{info, warn}; -use serde::Serialize; - -use config::BeeConfig; -use nest::lifecycle; - -#[derive(Debug, Clone)] -pub enum RestartPolicy { - Always, - OnFailure { max_retries: u32, backoff_ms: u64 }, - Never, -} - -impl RestartPolicy { - fn from_config(c: &BeeConfig) -> Self { - match c.restart.as_str() { - "always" => RestartPolicy::Always, - "on-failure" => RestartPolicy::OnFailure { max_retries: c.max_retries, backoff_ms: c.backoff_ms }, - "never" => RestartPolicy::Never, - _ => RestartPolicy::Always, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct BeeStatus { - pub kind: String, - pub pid: Option, - pub state: String, // "running" | "exited" | "crash-loop" | "stopped" - pub restart_count: u32, - pub last_exit_code: Option, -} - -struct BeeSlot { - kind: String, - cancel: CancellationToken, - pid: Option, - state: String, - restart_count: u32, - last_exit_code: Option, -} - -pub struct Supervisor { - slots: RwLock>>>, -} - -impl Supervisor { - pub fn new() -> Self { - Self { slots: RwLock::new(HashMap::new()) } - } - - pub async fn spawn_all(self: Arc, bees: Vec) { - for b in bees { - if let Err(e) = self.clone().spawn_one(b).await { - warn!(err = %e, "humnest.spawn.failed"); - } - } - } - - pub async fn spawn_one(self: Arc, cfg: BeeConfig) -> Result<()> { - let kind = cfg.kind.clone(); - let policy = RestartPolicy::from_config(&cfg); - let cancel = CancellationToken::new(); - let slot = Arc::new(RwLock::new(BeeSlot { - kind: kind.clone(), - cancel: cancel.clone(), - pid: None, - state: "spawning".into(), - restart_count: 0, - last_exit_code: None, - })); - self.slots.write().insert(kind.clone(), slot.clone()); - - let this = self.clone(); - tokio::spawn(async move { - let mut retries = 0u32; - loop { - let argv = if cfg.argv.is_empty() { - vec![format!("hum-{}-worker", cfg.kind)] - } else { cfg.argv.clone() }; - - let mut cmd = Command::new(&argv[0]); - cmd.args(&argv[1..]); - for (k, v) in &cfg.env { cmd.env(k, v); } - - let mut child = match cmd.group_spawn() { - Ok(c) => c, - Err(e) => { - warn!(kind = %cfg.kind, err = %e, "humnest.bee.spawn_failed"); - slot.write().state = "exited".into(); - return; - } - }; - let pid = child.inner().id(); - slot.write().pid = pid; - slot.write().state = "running".into(); - info!(kind = %cfg.kind, pid = ?pid, "humnest.bee.spawned"); - - let (rx_exit, child_cancel) = lifecycle::supervise(child); - - tokio::select! { - code = rx_exit => { - let code = code.unwrap_or(1); - slot.write().last_exit_code = Some(code); - slot.write().pid = None; - info!(kind = %cfg.kind, code, "humnest.bee.exited"); - - if cancel.is_cancelled() { - slot.write().state = "stopped".into(); - return; - } - - match &policy { - RestartPolicy::Never => { - slot.write().state = "exited".into(); - return; - } - RestartPolicy::OnFailure { max_retries, .. } if code == 0 => { - slot.write().state = "exited".into(); - return; - } - RestartPolicy::OnFailure { max_retries, backoff_ms } => { - retries += 1; - if retries > *max_retries { - slot.write().state = "crash-loop".into(); - warn!(kind = %cfg.kind, retries, "humnest.bee.crash_loop"); - return; - } - slot.write().restart_count = retries; - tokio::time::sleep(Duration::from_millis(*backoff_ms)).await; - } - RestartPolicy::Always => { - retries += 1; - slot.write().restart_count = retries; - tokio::time::sleep(Duration::from_millis(cfg.backoff_ms)).await; - } - } - } - _ = cancel.cancelled() => { - child_cancel.cancel(); - slot.write().state = "stopped".into(); - info!(kind = %cfg.kind, "humnest.bee.stopping"); - return; - } - } - } - }); - let _ = this; - Ok(()) - } - - pub async fn kill_one(&self, kind: &str) -> Result<()> { - if let Some(slot) = self.slots.read().get(kind).cloned() { - slot.read().cancel.cancel(); - Ok(()) - } else { - anyhow::bail!("no such bee: {kind}"); - } - } - - pub async fn kill_all(&self) { - let cancels: Vec<_> = self.slots.read().values().map(|s| s.read().cancel.clone()).collect(); - for c in cancels { c.cancel(); } - } - - pub fn list(&self) -> Vec { - self.slots.read().values().map(|s| { - let s = s.read(); - BeeStatus { - kind: s.kind.clone(), - pid: s.pid, - state: s.state.clone(), - restart_count: s.restart_count, - last_exit_code: s.last_exit_code, - } - }).collect() - } -} diff --git a/install b/install index d9badb50..841a2251 100755 --- a/install +++ b/install @@ -1,27 +1,21 @@ #!/usr/bin/env bash # hum installer. # -# Builds humd, humnest, humctl, hum. Registers humd + humnest as user -# services (systemd Linux / launchd macOS); humd's unit wants humnest's -# so the two come up together. humctl operates humd at runtime; -# humnest is reached via `hum nest` / `hum bee`. -# -# Bees are NOT installed as separate user units. humnest reads -# hum.json humnest.bees and supervises each child itself. Per-hive -# installers append to that list and bounce humnest. +# Builds + registers humd as a user service. Pulls orch + orchd binaries +# (bee supervisor). After this, `hum hive install ` brings up bees +# via orchd; no per-bee bash scripts. # # Idempotent — re-running upgrades in place. # # Usage: # ./install # from a cloned repo -# ./install status # daemon health (both humd + humnest) -# ./install logs # journalctl / launchd log tail -# ./install uninstall # stop + remove both units (keeps state) +# ./install status # humd state + paths +# ./install logs # tail humd logs +# ./install uninstall # stop + remove humd unit (state preserved) # ./install purge # remove everything INCLUDING state set -euo pipefail -# ─── XDG dirs ──────────────────────────────────────────────────────────────── XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" @@ -30,62 +24,38 @@ HUM_DATA="$XDG_DATA_HOME/hum" HUM_STATE="$XDG_STATE_HOME/hum" HUM_BIN_ROOT="$HOME/.local" HUMD_BIN="$HUM_BIN_ROOT/bin/humd" -HUMNEST_BIN="$HUM_BIN_ROOT/bin/humnest" HUMCTL_BIN="$HUM_BIN_ROOT/bin/humctl" HUM_CLI_BIN="$HUM_BIN_ROOT/bin/hum" - -if [ -n "${XDG_RUNTIME_DIR:-}" ]; then - HUM_RUNTIME="$XDG_RUNTIME_DIR/hum" -else - HUM_RUNTIME="/tmp/hum-$(id -u)" -fi -# Canonical per WIRE.md. All clients (thrum-core / thrum-clients/{ts,python,go}) -# resolve here. Override at runtime via HUM_THRUM_SOCK env if you must. -THRUM_SOCK="$HUM_RUNTIME/thrum.sock" +ORCH_BIN="$HUM_BIN_ROOT/bin/orch" +ORCHD_BIN="$HUM_BIN_ROOT/bin/orchd" HUM_REPO_URL="${HUM_REPO_URL:-https://github.com/adiled/hum.git}" HUM_SRC="$HUM_DATA/src" - MIN_CLAUDE="2.1.86" CMD="${1:-install}" OS="$(uname -s)" -# ─── helpers ───────────────────────────────────────────────────────────────── - log() { printf '\033[1m[hum]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[hum]\033[0m %s\n' "$*" >&2; } fail() { printf '\033[1;31m[hum]\033[0m %s\n' "$*" >&2; exit 1; } version_gte() { printf '%s\n%s\n' "$2" "$1" | sort -V -C; } - -read_version() { - "$1" --version 2>/dev/null | sed -n 's/.*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1 -} +read_version() { "$1" --version 2>/dev/null | sed -n 's/.*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -1; } ensure_min_version() { local NAME="$1" BIN="$2" MIN="$3" - if ! command -v "$BIN" >/dev/null 2>&1; then - fail "$NAME not found on PATH. Install it and re-run." - fi + if ! command -v "$BIN" >/dev/null 2>&1; then fail "$NAME not found on PATH."; fi local CUR; CUR="$(read_version "$BIN" || true)" if [ -z "$CUR" ]; then fail "$NAME found but couldn't read version."; fi - if version_gte "$CUR" "$MIN"; then - log "$NAME ${CUR} ≥ ${MIN} ✓" - else - fail "$NAME ${CUR} < ${MIN} — upgrade and re-run." - fi + if version_gte "$CUR" "$MIN"; then log "$NAME ${CUR} ≥ ${MIN} ✓" + else fail "$NAME ${CUR} < ${MIN} — upgrade and re-run."; fi } -# ─── prerequisite checks ───────────────────────────────────────────────────── - ensure_prereqs() { ensure_min_version "claude CLI" "claude" "$MIN_CLAUDE" if ! command -v cargo >/dev/null 2>&1; then - # Dev paradigm: if pre-built binaries are already present (e.g. placed by - # ./dev/deploy), allow proceeding so the installer can still register - # services. Otherwise building from source is required. - if [ -x "$HUMD_BIN" ] && [ -x "$HUMNEST_BIN" ] && [ -x "$HUMCTL_BIN" ]; then + if [ -x "$HUMD_BIN" ] && [ -x "$HUMCTL_BIN" ] && [ -x "$HUM_CLI_BIN" ] && [ -x "$ORCHD_BIN" ]; then warn "cargo not found; using pre-built binaries in $HUM_BIN_ROOT/bin" SKIP_BUILD=1 else @@ -94,16 +64,11 @@ ensure_prereqs() { fi } -# ─── identity ──────────────────────────────────────────────────────────────── - ensure_identity() { mkdir -p "$HUM_STATE" local KEY="$HUM_STATE/humd.key" - if [ -s "$KEY" ]; then - log "humd identity: $KEY ✓" - return 0 - fi - log "minting humd identity at $KEY (Ed25519, 32 bytes)" + if [ -s "$KEY" ]; then log "humd identity: $KEY ✓"; return 0; fi + log "minting humd identity at $KEY" if command -v openssl >/dev/null 2>&1; then openssl genpkey -algorithm Ed25519 -outform DER -out "$KEY.tmp" 2>/dev/null tail -c 32 "$KEY.tmp" > "$KEY" @@ -114,35 +79,22 @@ ensure_identity() { chmod 600 "$KEY" } -# ─── peers.json ────────────────────────────────────────────────────────────── - ensure_peers_config() { mkdir -p "$HUM_CONFIG" local PEERS="$HUM_CONFIG/peers.json" - if [ -s "$PEERS" ]; then - log "peers.json: $PEERS ✓" - return 0 - fi + if [ -s "$PEERS" ]; then log "peers.json: $PEERS ✓"; return 0; fi echo "[]" > "$PEERS" log "wrote empty peers.json at $PEERS" } -# ─── hum.json (0.3 namespaced) ────────────────────────────────────────────── - ensure_hum_config() { mkdir -p "$HUM_CONFIG" local CFG="$HUM_CONFIG/hum.json" - if [ -s "$CFG" ]; then - log "hum.json: $CFG ✓" - return 0 - fi + if [ -s "$CFG" ]; then log "hum.json: $CFG ✓"; return 0; fi cat > "$CFG" </install." } -# ─── build the three Rust binaries ─────────────────────────────────────────── +ensure_orch_overlay_dir() { + mkdir -p "$HUM_CONFIG/orch.d" + : > "$HUM_CONFIG/Orchfile" + log "orch overlay dir: $HUM_CONFIG/orch.d/" +} build_binaries() { local SRC @@ -182,32 +129,29 @@ build_binaries() { fi SRC="$HUM_SRC" fi - log "building Rust binaries via cargo install (may take a few minutes)" - cargo install --quiet --locked --path "$SRC/humd" --root "$HUM_BIN_ROOT" --force - cargo install --quiet --locked --path "$SRC/humnest" --root "$HUM_BIN_ROOT" --force - cargo install --quiet --locked --path "$SRC/humctl" --root "$HUM_BIN_ROOT" --force - cargo install --quiet --locked --path "$SRC/hum" --root "$HUM_BIN_ROOT" --force - [ -x "$HUMD_BIN" ] || fail "humd binary not at $HUMD_BIN after build" - [ -x "$HUMNEST_BIN" ] || fail "humnest binary not at $HUMNEST_BIN after build" - [ -x "$HUMCTL_BIN" ] || fail "humctl binary not at $HUMCTL_BIN after build" - [ -x "$HUM_CLI_BIN" ] || fail "hum binary not at $HUM_CLI_BIN after build" - log "humd installed at $HUMD_BIN" - log "humnest installed at $HUMNEST_BIN" - log "humctl installed at $HUMCTL_BIN" - log "hum installed at $HUM_CLI_BIN" + log "building Rust binaries" + cargo install --quiet --locked --path "$SRC/humd" --root "$HUM_BIN_ROOT" --force + cargo install --quiet --locked --path "$SRC/humctl" --root "$HUM_BIN_ROOT" --force + cargo install --quiet --locked --path "$SRC/hum" --root "$HUM_BIN_ROOT" --force + log "installing orch + orchd (bee supervisor) from git" + cargo install --quiet --git https://github.com/adiled/orch.git --root "$HUM_BIN_ROOT" --force + cargo install --quiet --git https://github.com/adiled/orchd.git --root "$HUM_BIN_ROOT" --force + [ -x "$HUMD_BIN" ] || fail "humd not at $HUMD_BIN after build" + [ -x "$HUMCTL_BIN" ] || fail "humctl not at $HUMCTL_BIN after build" + [ -x "$HUM_CLI_BIN" ] || fail "hum not at $HUM_CLI_BIN after build" + [ -x "$ORCH_BIN" ] || fail "orch not at $ORCH_BIN after build" + [ -x "$ORCHD_BIN" ] || fail "orchd not at $ORCHD_BIN after build" + log "humd $HUMD_BIN ; humctl $HUMCTL_BIN ; hum $HUM_CLI_BIN ; orch $ORCH_BIN ; orchd $ORCHD_BIN" } -# ─── service unit registration (inline; the install script's concern) ────── - linux_unit_dir="${XDG_CONFIG_HOME}/systemd/user" darwin_unit_dir="$HOME/Library/LaunchAgents" -write_linux_units() { +write_linux_unit() { mkdir -p "$linux_unit_dir" cat > "$linux_unit_dir/humd.service" < "$linux_unit_dir/humnest.service" </dev/null 2>&1 || true - systemctl --user restart humd.service humnest.service + systemctl --user enable humd.service >/dev/null 2>&1 || true + systemctl --user restart humd.service } -write_darwin_units() { +write_darwin_unit() { mkdir -p "$darwin_unit_dir" cat > "$darwin_unit_dir/humd.plist" < @@ -250,135 +180,94 @@ write_darwin_units() { StandardOutPath$HOME/Library/Logs/sh.hum.humd.out.log StandardErrorPath$HOME/Library/Logs/sh.hum.humd.err.log -EOF - cat > "$darwin_unit_dir/humnest.plist" < - - - Labelhumnest - Program$HUMNEST_BIN - RunAtLoad - KeepAlive - StandardOutPath$HOME/Library/Logs/sh.hum.humnest.out.log - StandardErrorPath$HOME/Library/Logs/sh.hum.humnest.err.log - EOF local uid; uid="$(id -u)" - launchctl bootout "gui/$uid/humd" 2>/dev/null || true - launchctl bootout "gui/$uid/humnest" 2>/dev/null || true + launchctl bootout "gui/$uid/humd" 2>/dev/null || true launchctl bootstrap "gui/$uid" "$darwin_unit_dir/humd.plist" - launchctl bootstrap "gui/$uid" "$darwin_unit_dir/humnest.plist" } start_daemons() { case "$OS" in - Linux) write_linux_units ;; - Darwin) write_darwin_units ;; - *) warn "service install not supported on $OS — run '$HUMD_BIN' and '$HUMNEST_BIN' manually"; return 0 ;; + Linux) write_linux_unit ;; + Darwin) write_darwin_unit ;; + *) warn "service install not supported on $OS — run '$HUMD_BIN' manually"; return 0 ;; esac - log "humd + humnest registered + started ($OS)" + log "humd registered + started ($OS)" sleep 1 } -# ─── next steps ────────────────────────────────────────────────────────────── - -# humd has no opinion on which bees you run. Each bee is a -# separate process; humnest supervises them via hum.json humnest.bees. print_next_steps() { log "" - log "humd + humnest are up. Add a bee to give them an outside-world surface:" - log "" - log " hum + opencode (chat from the opencode TUI/CLI):" - log " ./recipes/opencode/install" + log "humd is up. Add a bee:" + log " hum hive install claude-cli # claude worker (cargo build + register)" + log " hum hive install humfs # filesystem forager" + log " hum hive install paid-oracle # x402 paid price oracle" log "" - log " Other bees — each one appends itself to humnest.bees:" - log " hives/openai-server/install (OpenAI-shape HTTP)" - log " hives/claude-cli/install (Claude CLI worker)" - log " hives/humfs/install (filesystem forager)" - log "" - log "Health checks:" - log " ./install status (is everything alive)" - log " ./install logs (recent humd + humnest logs)" + log "Bee state: hum bee --list / hum nest" + log "humd state: hum status / hum doctor / humctl logs" } -# ─── status / logs / uninstall ─────────────────────────────────────────────── - show_status() { echo "humd binary: $HUMD_BIN" echo " version: $($HUMD_BIN --version 2>&1 || echo 'not installed')" - echo "humnest binary: $HUMNEST_BIN" - echo " version: $($HUMNEST_BIN --version 2>&1 || echo 'not installed')" + echo "orchd binary: $ORCHD_BIN" + echo " version: $($ORCHD_BIN --version 2>&1 || echo 'not installed')" echo "identity: $HUM_STATE/humd.key $([ -s "$HUM_STATE/humd.key" ] && echo ✓ || echo MISSING)" echo "peers.json: $HUM_CONFIG/peers.json $([ -s "$HUM_CONFIG/peers.json" ] && echo ✓ || echo MISSING)" echo "hum.json: $HUM_CONFIG/hum.json $([ -s "$HUM_CONFIG/hum.json" ] && echo ✓ || echo MISSING)" - echo "thrum socket: $THRUM_SOCK $([ -S "$THRUM_SOCK" ] && echo ✓ || echo absent)" case "$OS" in - Linux) - systemctl --user status humd --no-pager 2>&1 | head -6 || true - systemctl --user status humnest --no-pager 2>&1 | head -6 || true - ;; - Darwin) - launchctl print "gui/$(id -u)/humd" 2>&1 | head -6 || true - launchctl print "gui/$(id -u)/humnest" 2>&1 | head -6 || true - ;; + Linux) systemctl --user status humd --no-pager 2>&1 | head -6 || true ;; + Darwin) launchctl print "gui/$(id -u)/humd" 2>&1 | head -6 || true ;; esac + if [ -x "$ORCHD_BIN" ]; then + echo "--- bees ---" + "$ORCHD_BIN" --orchfile "$HUM_CONFIG/Orchfile" --user --namespace hum status 2>&1 || true + fi } show_logs() { - case "$OS" in - Linux) - journalctl --user -u humd --no-pager -n 100 - echo "---" - journalctl --user -u humnest --no-pager -n 100 - ;; - Darwin) - tail -n 100 "$HOME/Library/Logs/humd.out.log" "$HOME/Library/Logs/humd.err.log" 2>/dev/null - echo "---" - tail -n 100 "$HOME/Library/Logs/humnest.out.log" "$HOME/Library/Logs/humnest.err.log" 2>/dev/null - ;; - *) warn "logs command unavailable on $OS" ;; - esac + if [ -x "$HUMCTL_BIN" ]; then + "$HUMCTL_BIN" logs -n 100 + else + warn "humctl not installed" + fi } uninstall() { case "$OS" in Linux) - systemctl --user stop humnest.service humd.service 2>/dev/null || true - systemctl --user disable humnest.service humd.service 2>/dev/null || true - rm -f "$linux_unit_dir/humd.service" "$linux_unit_dir/humnest.service" + systemctl --user stop humd.service 2>/dev/null || true + systemctl --user disable humd.service 2>/dev/null || true + rm -f "$linux_unit_dir/humd.service" systemctl --user daemon-reload 2>/dev/null || true ;; Darwin) local uid; uid="$(id -u)" - launchctl bootout "gui/$uid/humnest" 2>/dev/null || true - launchctl bootout "gui/$uid/humd" 2>/dev/null || true - rm -f "$darwin_unit_dir/humnest.plist" "$darwin_unit_dir/humd.plist" + launchctl bootout "gui/$uid/humd" 2>/dev/null || true + rm -f "$darwin_unit_dir/humd.plist" ;; esac - rm -f "$HUMD_BIN" "$HUMNEST_BIN" "$HUM_CLI_BIN" "$HUMCTL_BIN" - log "uninstalled. State preserved in $HUM_STATE + $HUM_CONFIG." + rm -f "$HUMD_BIN" "$HUMCTL_BIN" "$HUM_CLI_BIN" + log "uninstalled humd. orch/orchd left in place. State preserved in $HUM_STATE + $HUM_CONFIG." log " run \`./install purge\` to remove state too." } purge() { uninstall rm -rf "$HUM_STATE" "$HUM_CONFIG" "$HUM_DATA" - log "purged all hum state." + log "purged." } -# ─── dispatch ──────────────────────────────────────────────────────────────── - do_install() { - log "hum installer — v0.4 (humd + humnest)" + log "hum installer" ensure_prereqs if [ "${SKIP_BUILD:-0}" != "1" ]; then build_binaries; fi ensure_identity ensure_peers_config ensure_hum_config + ensure_orch_overlay_dir start_daemons log "" - log "humd + humnest installed. Try: ./install status / ./install logs" - log "" print_next_steps } From 1e19cb6fa3c07fdae6fac2b1dcbd30e2c90252dc Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 20:13:06 +0000 Subject: [PATCH 10/18] hum hive install: multi-language build dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects build kind by marker file: - Cargo.toml → cargo install --path - package.json → pnpm/npm install + build; writes ~/.local/bin/ as a node wrapper exec'ing the produced dist/index.js (honors pre-built dist if no pnpm/npm in PATH) - go.mod → go build -o ~/.local/bin/ - 'build' script → execute it (escape hatch for exotic hives) Orchfiles added for the remaining hives (every hive now ships one): - bp7, grpc, gsm-modem, ollama-server (Rust foragers; bp7-forager, grpc-forager, ollama-server, gsm-modem binaries) - openai-server, anthropic-server, vercel-ai (TS HTTP foragers) - twilio-sms (Go forager) 12 hives total, all surface-uniform: ~/.local/bin/ + Orchfile. hives/openai-server/install bash deleted — the build pipeline (pnpm install + tsup build + node wrapper) now lives in hum CLI's build_node(). One code path for all TS hives. 247 tests pass. --- hives/anthropic-server/Orchfile | 3 + hives/bp7/Orchfile | 3 + hives/grpc/Orchfile | 3 + hives/gsm-modem/Orchfile | 3 + hives/ollama-server/Orchfile | 3 + hives/openai-server/Orchfile | 3 + hives/openai-server/install | 158 -------------------------------- hives/twilio-sms/Orchfile | 3 + hives/vercel-ai/Orchfile | 3 + hum/src/main.rs | 101 +++++++++++++++++--- 10 files changed, 112 insertions(+), 171 deletions(-) create mode 100644 hives/anthropic-server/Orchfile create mode 100644 hives/bp7/Orchfile create mode 100644 hives/grpc/Orchfile create mode 100644 hives/gsm-modem/Orchfile create mode 100644 hives/ollama-server/Orchfile create mode 100644 hives/openai-server/Orchfile delete mode 100755 hives/openai-server/install create mode 100644 hives/twilio-sms/Orchfile create mode 100644 hives/vercel-ai/Orchfile diff --git a/hives/anthropic-server/Orchfile b/hives/anthropic-server/Orchfile new file mode 100644 index 00000000..4d10209e --- /dev/null +++ b/hives/anthropic-server/Orchfile @@ -0,0 +1,3 @@ +SERVICE anthropic-server +RUN ${HOME}/.local/bin/anthropic-server +RESTART always diff --git a/hives/bp7/Orchfile b/hives/bp7/Orchfile new file mode 100644 index 00000000..fb2c7213 --- /dev/null +++ b/hives/bp7/Orchfile @@ -0,0 +1,3 @@ +SERVICE bp7 +RUN ${HOME}/.local/bin/bp7-forager +RESTART always diff --git a/hives/grpc/Orchfile b/hives/grpc/Orchfile new file mode 100644 index 00000000..04d3b73d --- /dev/null +++ b/hives/grpc/Orchfile @@ -0,0 +1,3 @@ +SERVICE grpc +RUN ${HOME}/.local/bin/grpc-forager +RESTART always diff --git a/hives/gsm-modem/Orchfile b/hives/gsm-modem/Orchfile new file mode 100644 index 00000000..dce36d9f --- /dev/null +++ b/hives/gsm-modem/Orchfile @@ -0,0 +1,3 @@ +SERVICE gsm-modem +RUN ${HOME}/.local/bin/gsm-modem +RESTART always diff --git a/hives/ollama-server/Orchfile b/hives/ollama-server/Orchfile new file mode 100644 index 00000000..8a32a5d3 --- /dev/null +++ b/hives/ollama-server/Orchfile @@ -0,0 +1,3 @@ +SERVICE ollama-server +RUN ${HOME}/.local/bin/ollama-server +RESTART always diff --git a/hives/openai-server/Orchfile b/hives/openai-server/Orchfile new file mode 100644 index 00000000..ae419b33 --- /dev/null +++ b/hives/openai-server/Orchfile @@ -0,0 +1,3 @@ +SERVICE openai-server +RUN ${HOME}/.local/bin/openai-server +RESTART always diff --git a/hives/openai-server/install b/hives/openai-server/install deleted file mode 100755 index c3003440..00000000 --- a/hives/openai-server/install +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env bash -# openai-server bee installer. -# -# Builds the TS bundle, seeds the per-kind config at -# ~/.config/hum/hives/openai-server.json, and registers the bee with -# humnest via hum.json humnest.bees. humnest supervises it; the bee -# pairs with humd via the thrum socket. -# -# Usage: -# hives/openai-server/install # build + register + bounce humnest -# hives/openai-server/install uninstall # remove from humnest.bees -# -# Env: -# OPENAI_SERVER_PORT default 14620 -# OPENAI_SERVER_HOST default 127.0.0.1 -# HUM_THRUM_SOCK default $XDG_RUNTIME_DIR/hum/thrum.sock -set -euo pipefail - -XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" -HUM_CONFIG="$XDG_CONFIG_HOME/hum" -HUM_JSON="$HUM_CONFIG/hum.json" -PORT="${OPENAI_SERVER_PORT:-14620}" -HOST="${OPENAI_SERVER_HOST:-127.0.0.1}" - -if [ -n "${XDG_RUNTIME_DIR:-}" ]; then - DEFAULT_SOCK="$XDG_RUNTIME_DIR/hum/thrum.sock" -else - DEFAULT_SOCK="/tmp/hum-$(id -u)/thrum.sock" -fi -THRUM_SOCK="${HUM_THRUM_SOCK:-$DEFAULT_SOCK}" - -NESTDIR="$(cd "$(dirname "$0")" && pwd)" -KIND="openai-server" -CFG="$HUM_CONFIG/hives/$KIND.json" -HUMCTL_BIN="$HOME/.local/bin/humctl" - -log() { printf '\033[1m[%s]\033[0m %s\n' "$KIND" "$*"; } -warn() { printf '\033[1;33m[%s]\033[0m %s\n' "$KIND" "$*" >&2; } -fail() { printf '\033[1;31m[%s]\033[0m %s\n' "$KIND" "$*" >&2; exit 1; } - -CMD="${1:-install}" - -build() { - local DIST="$NESTDIR/dist/index.js" - # Pre-built dist (e.g. produced upstream by `./dev/deploy`) is honored - # so machines without pnpm can still install. - if [ -s "$DIST" ] && ! command -v pnpm >/dev/null 2>&1; then - log "pnpm absent; using pre-built $DIST" - return 0 - fi - command -v pnpm >/dev/null 2>&1 || fail "pnpm required. https://pnpm.io/installation" - log "building (pnpm install + build) in $NESTDIR" - (cd "$NESTDIR" && pnpm install --silent >/dev/null 2>&1) - (cd "$NESTDIR" && pnpm run build --silent) - [ -s "$DIST" ] || fail "build did not produce $DIST" - log "built: $DIST" -} - -# Locate node binary. Prefers $PATH; falls back to fnm's default -# installation so people whose login shell doesn't source fnm still -# get a working install. -find_node() { - if command -v node >/dev/null 2>&1; then - command -v node - return 0 - fi - local FNM_DIR="${FNM_DIR:-$HOME/.local/share/fnm}" - if [ -d "$FNM_DIR/node-versions" ]; then - local CANDIDATE - CANDIDATE="$(ls -1 "$FNM_DIR/node-versions" 2>/dev/null | sort -V | tail -1)" - if [ -x "$FNM_DIR/node-versions/$CANDIDATE/installation/bin/node" ]; then - echo "$FNM_DIR/node-versions/$CANDIDATE/installation/bin/node" - return 0 - fi - fi - return 1 -} - -seed_config() { - mkdir -p "$(dirname "$CFG")" - if [ -s "$CFG" ]; then - log "config exists: $CFG ✓" - return 0 - fi - cat > "$CFG" </dev/null 2>&1 || fail "jq required to edit $HUM_JSON" - [ -s "$HUM_JSON" ] || fail "$HUM_JSON missing — run the top-level ./install first" - local NODE_BIN - NODE_BIN="$(find_node || true)" - [ -n "$NODE_BIN" ] || fail "node missing. Install node ≥ 18." - log "node: $NODE_BIN" - - local DIST="$NESTDIR/dist/index.js" - local bee_json - bee_json=$(jq -n \ - --arg kind "$KIND" \ - --arg node "$NODE_BIN" \ - --arg script "$DIST" \ - --arg config_home "$XDG_CONFIG_HOME" \ - --arg runtime "${XDG_RUNTIME_DIR:-/tmp/hum-$(id -u)}" \ - --arg sock "$THRUM_SOCK" \ - '{kind: $kind, argv: [$node, $script], env: { - XDG_CONFIG_HOME: $config_home, - XDG_RUNTIME_DIR: $runtime, - HUM_THRUM_SOCK: $sock - }}') - - local tmp; tmp=$(mktemp) - jq --argjson bee "$bee_json" ' - .humnest //= {bees: []} - | .humnest.bees //= [] - | .humnest.bees |= (map(select(.kind != $bee.kind)) + [$bee]) - ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" - log "registered $KIND in humnest.bees on :$PORT" - log " test: curl http://$HOST:$PORT/v1/models" -} - -bounce_humnest() { - [ -x "$HUMCTL_BIN" ] || { warn "humctl not at $HUMCTL_BIN — start humnest manually"; return 0; } - "$HUMCTL_BIN" restart humnest 2>/dev/null \ - || "$HUMCTL_BIN" start humnest 2>/dev/null \ - || warn "could not restart humnest via humctl" - log "humnest bounced — $KIND should spawn on its next tick" -} - -uninstall() { - command -v jq >/dev/null 2>&1 || fail "jq required to edit $HUM_JSON" - if [ -s "$HUM_JSON" ]; then - local tmp; tmp=$(mktemp) - jq --arg kind "$KIND" ' - .humnest.bees |= (. // [] | map(select(.kind != $kind))) - ' "$HUM_JSON" > "$tmp" && mv "$tmp" "$HUM_JSON" - log "removed $KIND from humnest.bees" - fi - [ -x "$HUMCTL_BIN" ] && "$HUMCTL_BIN" restart humnest 2>/dev/null || true - log "uninstalled. Config at $CFG preserved." -} - -case "$CMD" in - install|"") - build - seed_config - register_with_humnest - bounce_humnest - ;; - uninstall) uninstall ;; - *) fail "unknown command: $CMD (try: install, uninstall)" ;; -esac diff --git a/hives/twilio-sms/Orchfile b/hives/twilio-sms/Orchfile new file mode 100644 index 00000000..5b8af3c0 --- /dev/null +++ b/hives/twilio-sms/Orchfile @@ -0,0 +1,3 @@ +SERVICE twilio-sms +RUN ${HOME}/.local/bin/twilio-sms +RESTART always diff --git a/hives/vercel-ai/Orchfile b/hives/vercel-ai/Orchfile new file mode 100644 index 00000000..4c62b380 --- /dev/null +++ b/hives/vercel-ai/Orchfile @@ -0,0 +1,3 @@ +SERVICE vercel-ai +RUN ${HOME}/.local/bin/vercel-ai +RESTART always diff --git a/hum/src/main.rs b/hum/src/main.rs index 73295804..d1f35551 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -561,19 +561,7 @@ fn hive_install(reference: &str) -> Result<()> { let kind = read_orchfile_service(&orchfile)? .ok_or_else(|| anyhow::anyhow!("no SERVICE directive in {}", orchfile.display()))?; - if dir.join("Cargo.toml").exists() { - println!("building {kind} (cargo install --path {} ...)", dir.display()); - let s = Command::new("cargo") - .args(["install", "--quiet", "--locked", "--path"]).arg(&dir) - .args(["--root"]).arg(home_local()) - .arg("--force") - .status()?; - if !s.success() { anyhow::bail!("cargo install failed for {}", dir.display()); } - } else if dir.join("package.json").exists() { - anyhow::bail!("TS hive install not yet automated; run `pnpm install && pnpm build` in {}", dir.display()); - } else { - anyhow::bail!("no Cargo.toml or package.json in {} — don't know how to build", dir.display()); - } + build_hive(&dir, &kind)?; let orch_d = hum_paths::config_dir().join("orch.d"); std::fs::create_dir_all(&orch_d)?; @@ -590,6 +578,93 @@ fn hive_install(reference: &str) -> Result<()> { Ok(()) } +fn build_hive(dir: &Path, kind: &str) -> Result<()> { + let custom = dir.join("build"); + if custom.is_file() { + println!("building {kind} (./build in {}) ...", dir.display()); + let s = Command::new("bash").arg(&custom).current_dir(dir).status()?; + if !s.success() { anyhow::bail!("custom build script failed: {}", custom.display()); } + return Ok(()); + } + if dir.join("Cargo.toml").exists() { return build_cargo(dir, kind); } + if dir.join("package.json").exists() { return build_node(dir, kind); } + if dir.join("go.mod").exists() { return build_go(dir, kind); } + anyhow::bail!("no Cargo.toml / package.json / go.mod / build in {}", dir.display()); +} + +fn build_cargo(dir: &Path, kind: &str) -> Result<()> { + println!("building {kind} (cargo install --path {}) ...", dir.display()); + let s = Command::new("cargo") + .args(["install", "--quiet", "--locked", "--path"]).arg(dir) + .args(["--root"]).arg(home_local()) + .arg("--force") + .status()?; + if !s.success() { anyhow::bail!("cargo install failed for {}", dir.display()); } + Ok(()) +} + +fn build_node(dir: &Path, kind: &str) -> Result<()> { + let dist = dir.join("dist").join("index.js"); + let pkg_mgr = ["pnpm", "npm"].iter().find(|m| which(m)).copied(); + if let Some(mgr) = pkg_mgr { + println!("building {kind} ({mgr} install + build) in {}", dir.display()); + let s = Command::new(mgr).arg("install").current_dir(dir).status()?; + if !s.success() { anyhow::bail!("{mgr} install failed"); } + let s = Command::new(mgr).args(["run", "build"]).current_dir(dir).status()?; + if !s.success() { anyhow::bail!("{mgr} run build failed"); } + } else if dist.exists() { + println!("using pre-built {} (no pnpm/npm in PATH)", dist.display()); + } else { + anyhow::bail!("no pnpm/npm in PATH and no prebuilt {}", dist.display()); + } + if !dist.exists() { + anyhow::bail!("build did not produce {}", dist.display()); + } + let node = which_first(&["node", "/usr/local/bin/node"]) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/fnm/aliases/default/bin/node")).filter(|p| p.exists())) + .ok_or_else(|| anyhow::anyhow!("node not in PATH; install Node 22+"))?; + let bin = home_local().join("bin").join(kind); + std::fs::create_dir_all(bin.parent().unwrap())?; + let wrapper = format!( + "#!/usr/bin/env bash\nexec {} {} \"$@\"\n", + node.display(), dist.display(), + ); + std::fs::write(&bin, wrapper)?; + use std::os::unix::fs::PermissionsExt; + let mut perm = std::fs::metadata(&bin)?.permissions(); + perm.set_mode(0o755); + std::fs::set_permissions(&bin, perm)?; + println!("wrote node wrapper at {}", bin.display()); + Ok(()) +} + +fn build_go(dir: &Path, kind: &str) -> Result<()> { + if !which("go") { anyhow::bail!("go not in PATH; install Go"); } + let bin = home_local().join("bin").join(kind); + std::fs::create_dir_all(bin.parent().unwrap())?; + println!("building {kind} (go build) in {}", dir.display()); + let s = Command::new("go").args(["build", "-o"]).arg(&bin).arg(".").current_dir(dir).status()?; + if !s.success() { anyhow::bail!("go build failed"); } + Ok(()) +} + +fn which(name: &str) -> bool { + Command::new("sh").args(["-c", &format!("command -v {name} >/dev/null 2>&1")]) + .status().map(|s| s.success()).unwrap_or(false) +} + +fn which_first(candidates: &[&str]) -> Option { + for c in candidates { + if c.starts_with('/') { + let p = PathBuf::from(c); + if p.exists() { return Some(p); } + } else if which(c) { + return Some(PathBuf::from(c)); + } + } + None +} + fn read_orchfile_service(path: &Path) -> Result> { let raw = std::fs::read_to_string(path)?; Ok(raw.lines() From 6c8c4abfcbb94ab2e50f833d229b4475219a2f7b Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 20:25:46 +0000 Subject: [PATCH 11/18] hives/README: orchd flow + remove dead humnest/ForagerBee refs --- hives/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hives/README.md b/hives/README.md index c0182bcf..f6e104c8 100644 --- a/hives/README.md +++ b/hives/README.md @@ -107,7 +107,7 @@ In **local dev** you run `cargo run -p humd` and then launch your bee binary. Bo In the **ensemble**, a bee on one machine reaches a humd on another over the ensemble transport. The remote humd sees the same hello and routes normally. A `peers.json` with one bootstrap entry turns this on. Nothing installs on the remote humd's disk, and the `source` URL stays purely informational. -As a **managed service** you keep a bee alive across reboots. Ship a `hives//install` modeled on [`paid-oracle/install`](paid-oracle/install), which appends the bee to `hum.json` `humnest.bees` and bounces humnest. humnest then supervises the child; from there the CLI drives it. +As a **managed service** you keep a bee alive across reboots. Ship an `Orchfile` at your hive root declaring the SERVICE + RUN + RESTART (see any bundled hive for a template). `hum hive install ` resolves the target, builds the binary (Cargo / pnpm / Go / `build` script — detected from the marker file present), copies the Orchfile into `~/.config/hum/orch.d/`, and asks [orchd](https://github.com/adiled/orchd) to bring the bee up as a user systemd unit (Linux) or launchd agent (macOS). orchd is the supervisor; from there the CLI drives it. ``` hum hive --list # catalogue: installer, configured, running @@ -118,7 +118,7 @@ hum bee exit # stop it, preserving state hum bee reenter # graceful restart with the same identity ``` -`hum bee reenter` is the supported replacement for `pkill`, because it restarts through the service manager and the bee keeps its persisted identity. `hum hive install` accepts the same dialect a bee advertises in its `source`: a bundled name, a local path, or a `github.com///tree//` URL. Our own repo resolves to the local checkout, and a foreign one is shallow-cloned. +`hum bee reenter` is the supported replacement for `pkill`, because it restarts through orchd and the bee keeps its persisted identity. `hum hive install` accepts the same dialect a bee advertises in its `source`: a bundled name, a local path, or a `github.com///tree//` URL. Our own repo resolves to the local checkout, and a foreign one is shallow-cloned. The target must contain an `Orchfile`. ## Discovery, optional @@ -130,6 +130,6 @@ For mesh discovery, the ensemble gossips your manifest on `hum/hives/announce`, ## See also -- [`nest/`](../nest) defines `WorkerBee`, `ForagerBee`, `Cell`, `SpawnSpec`, and the encoders. +- [`nest/`](../nest) defines `WorkerBee`, `Cell`, `SpawnSpec`, and the encoders. Process lifecycle (supervise, tree-kill, reap) lives in `nest::lifecycle`. - [WIRE.md](../WIRE.md) explains what the wire sees of nests and cells. - [VOCABULARY](../VOCABULARY.md) holds the canonical entries for nest, hive, bee, worker, forager, nestler, and nestled. From 039f711d6bdf004018786dcf863291bd00dad080 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sat, 30 May 2026 20:56:51 +0000 Subject: [PATCH 12/18] =?UTF-8?q?nest=20surface:=20engineering=20names=20?= =?UTF-8?q?=E2=86=92=20bee=20biology?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constitution-level rename. Where hum owns the names, they sing. SpawnSpec → Egg (the thing-to-be a worker raises) WorkerBee::spawn → ::raise (workers raise brood) Cell.pid → Cell.mark (beekeeper's mark) Cell.stdin → Cell.feed (nurses feed larvae through open cells) Cell.events → Cell.mmm (the sounds from inside the cell) Cell.exited → Cell.emerged (adult bee chews out the cap and emerges) Cell.cancel → Cell.silence + Cell.still() (token field, verb method) ResourceLimits → Bounds resource_limits→ bounds CellMetrics → Vitals Attachment → Pollen (what a forager carries home) encode_prompt_with_attachments → encode_prompt_with_pollen nest::lifecycle::supervise → ::tend (nurse bees tend brood cells) Foreign types stay foreign (CancellationToken, mpsc::Sender, AsyncGroupChild, tokio::process::Command, oneshot::Receiver). We don't own those words. Our surface either reads like apiary biology or stays out of the way. 247 tests pass. --- hives/claude-cli/src/lib.rs | 42 +++++++++---------- hives/claude-repl/src/lib.rs | 16 ++++---- hives/common/src/serve.rs | 26 ++++++------ nest/src/lib.rs | 78 +++++++++++++++--------------------- nest/src/lifecycle.rs | 8 ++-- nest/src/limits.rs | 20 ++++----- nest/src/metrics.rs | 8 ++-- 7 files changed, 92 insertions(+), 106 deletions(-) diff --git a/hives/claude-cli/src/lib.rs b/hives/claude-cli/src/lib.rs index 8aeab946..0985cf5f 100644 --- a/hives/claude-cli/src/lib.rs +++ b/hives/claude-cli/src/lib.rs @@ -1,7 +1,7 @@ //! claude-cli — the pipe-mode WorkerBee for claude. //! //! `claude -p --input-format stream-json --output-format stream-json`. -//! Takes a [`nest::SpawnSpec`], builds the CLI invocation, runs the +//! Takes a [`nest::Egg`], builds the CLI invocation, runs the //! subprocess, exposes stdin/stdout/exit through [`nest::Cell`]. The //! daemon never sees claude-specific arg shapes — this crate owns them. @@ -18,7 +18,7 @@ use tokio::process::Command; use tokio::sync::{mpsc, Mutex}; use tracing::{trace, warn}; -use nest::{Propensity, Cell, SpawnSpec, WorkerBee}; +use nest::{Propensity, Cell, Egg, WorkerBee}; pub struct ClaudeCliWorker; @@ -26,11 +26,11 @@ impl Default for ClaudeCliWorker { fn default() -> Self { Self } } -/// Build the claude CLI argv from a [`SpawnSpec`]. +/// Build the claude CLI argv from a [`Egg`]. /// /// Public so it can be unit-tested without spawning a real process. Pure /// function — no IO. -pub fn build_argv(spec: &SpawnSpec) -> Vec { +pub fn build_argv(spec: &Egg) -> Vec { let mut argv = vec![ "-p".to_string(), "--verbose".to_string(), @@ -79,7 +79,7 @@ pub fn build_argv(spec: &SpawnSpec) -> Vec { /// Build the spawn env. claude is sensitive to a small set of toggles; /// callers can override anything via `spec.env`. -pub fn build_env(spec: &SpawnSpec) -> Vec<(String, String)> { +pub fn build_env(spec: &Egg) -> Vec<(String, String)> { let mut env: Vec<(String, String)> = vec![ ("CLAUDE_CODE_DISABLE_CLAUDE_MDS".into(), "1".into()), ("CLAUDE_CODE_DISABLE_AUTO_MEMORY".into(), "1".into()), @@ -109,7 +109,7 @@ impl WorkerBee for ClaudeCliWorker { fn ephemeral(&self) -> bool { false } fn propensity(&self) -> Propensity { Propensity::StatefulSession } - async fn spawn(&self, spec: SpawnSpec) -> Result { + async fn raise(&self, spec: Egg) -> Result { let cli = spec.cli_path.clone() .or_else(|| std::env::var("CLAUDE_CLI_PATH").ok()) .unwrap_or_else(|| "claude".into()); @@ -130,7 +130,7 @@ impl WorkerBee for ClaudeCliWorker { .stdout(Stdio::piped()) .stderr(Stdio::piped()); - spec.resource_limits.apply_pre_exec(cmd.as_std_mut()) + spec.bounds.apply_pre_exec(cmd.as_std_mut()) .with_context(|| "apply resource limits")?; let mut child = cmd.group_spawn().with_context(|| format!("spawn {cli}"))?; @@ -198,17 +198,17 @@ impl WorkerBee for ClaudeCliWorker { } }); - let (rx_exit, cancel) = nest::lifecycle::supervise(child); + let (rx_exit, cancel) = nest::lifecycle::tend(child); trace!(target: "claude-cli", "spawned pid={:?}", pid); Ok(Cell { - pid, - stdin: tx_in, - events: std::sync::Arc::new(Mutex::new(rx_evt)), - exited: rx_exit, + mark: pid, + feed: tx_in, + mmm: std::sync::Arc::new(Mutex::new(rx_evt)), + emerged: rx_exit, ephemeral: false, - cancel, + silence: cancel, }) } } @@ -219,7 +219,7 @@ mod tests { #[test] fn argv_includes_basics() { - let spec = SpawnSpec::new("sid-1", "claude-haiku-4-5", "/tmp"); + let spec = Egg::new("sid-1", "claude-haiku-4-5", "/tmp"); let argv = build_argv(&spec); assert!(argv.contains(&"-p".to_string())); assert!(argv.contains(&"--verbose".to_string())); @@ -230,7 +230,7 @@ mod tests { #[test] fn argv_omits_mcp_when_no_url() { - let spec = SpawnSpec::new("s", "m", "/"); + let spec = Egg::new("s", "m", "/"); let argv = build_argv(&spec); assert!(!argv.iter().any(|a| a == "--mcp-config")); assert!(!argv.iter().any(|a| a == "--strict-mcp-config")); @@ -238,7 +238,7 @@ mod tests { #[test] fn argv_includes_mcp_when_url_set() { - let mut spec = SpawnSpec::new("sid-9", "m", "/"); + let mut spec = Egg::new("sid-9", "m", "/"); spec.mcp_url = Some("http://127.0.0.1:29147".into()); let argv = build_argv(&spec); let idx = argv.iter().position(|a| a == "--mcp-config").expect("mcp-config flag"); @@ -252,7 +252,7 @@ mod tests { #[test] fn argv_includes_system_prompt() { - let mut spec = SpawnSpec::new("s", "m", "/"); + let mut spec = Egg::new("s", "m", "/"); spec.system_prompt = Some("Be terse.".into()); let argv = build_argv(&spec); let i = argv.iter().position(|a| a == "--system-prompt").unwrap(); @@ -261,7 +261,7 @@ mod tests { #[test] fn argv_includes_resume() { - let mut spec = SpawnSpec::new("s", "m", "/"); + let mut spec = Egg::new("s", "m", "/"); spec.resume_id = Some("abc-123".into()); let argv = build_argv(&spec); let i = argv.iter().position(|a| a == "--resume").unwrap(); @@ -270,14 +270,14 @@ mod tests { #[test] fn env_disables_adaptive_thinking_when_not_planning() { - let spec = SpawnSpec::new("s", "m", "/"); + let spec = Egg::new("s", "m", "/"); let env = build_env(&spec); assert!(env.iter().any(|(k, v)| k == "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING" && v == "1")); } #[test] fn env_keeps_adaptive_thinking_in_plan_mode() { - let mut spec = SpawnSpec::new("s", "m", "/"); + let mut spec = Egg::new("s", "m", "/"); spec.plan_mode = true; let env = build_env(&spec); assert!(!env.iter().any(|(k, _)| k == "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING")); @@ -285,7 +285,7 @@ mod tests { #[test] fn env_user_override_wins() { - let mut spec = SpawnSpec::new("s", "m", "/"); + let mut spec = Egg::new("s", "m", "/"); spec.env.insert("CLAUDE_CODE_DISABLE_FAST_MODE".into(), "0".into()); let env = build_env(&spec); let positions: Vec<(usize, &str)> = env.iter().enumerate() diff --git a/hives/claude-repl/src/lib.rs b/hives/claude-repl/src/lib.rs index 35ccb4ad..3189bf37 100644 --- a/hives/claude-repl/src/lib.rs +++ b/hives/claude-repl/src/lib.rs @@ -17,7 +17,7 @@ use serde_json::{json, Value}; use tokio::sync::{mpsc, oneshot, Mutex}; use tracing::{trace, warn}; -use nest::{Cell, SpawnSpec, WorkerBee}; +use nest::{Cell, Egg, WorkerBee}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum HarnessState { @@ -42,7 +42,7 @@ impl Default for ClaudeReplWorker { /// Build the claude args for REPL/PTY mode. No `-p`, no `--input-format` — /// interactive Ink TUI mode. Pure function for unit testing. -pub fn build_argv(spec: &SpawnSpec) -> Vec { +pub fn build_argv(spec: &Egg) -> Vec { let mut argv = vec![ "--verbose".to_string(), "--model".to_string(), spec.model_id.clone(), @@ -76,7 +76,7 @@ impl WorkerBee for ClaudeReplWorker { true } - async fn spawn(&self, spec: SpawnSpec) -> Result { + async fn raise(&self, spec: Egg) -> Result { let cli = spec.cli_path.clone().unwrap_or_else(|| "claude".into()); let pty_system = NativePtySystem::default(); let pair = pty_system @@ -203,12 +203,12 @@ impl WorkerBee for ClaudeReplWorker { trace!(target: "nest", "pty.spawned pid={:?}", pid); Ok(Cell { - pid, - stdin: tx_in, - events: std::sync::Arc::new(Mutex::new(rx_evt)), - exited: rx_exit, + mark: pid, + feed: tx_in, + mmm: std::sync::Arc::new(Mutex::new(rx_evt)), + emerged: rx_exit, ephemeral: true, - cancel, + silence: cancel, }) } } diff --git a/hives/common/src/serve.rs b/hives/common/src/serve.rs index cfb7b108..ca17c2ff 100644 --- a/hives/common/src/serve.rs +++ b/hives/common/src/serve.rs @@ -36,7 +36,7 @@ use tracing::{debug, info, trace, warn}; use ensemble::HidPrefix; use mcp::protocol::ToolDef; -use nest::{encode_cancel, encode_prompt, encode_tool_result, Cell, SpawnSpec, WorkerBee}; +use nest::{encode_cancel, encode_prompt, encode_tool_result, Cell, Egg, WorkerBee}; use tokio::sync::mpsc; use crate::identity::load_or_mint_bee_key; @@ -319,25 +319,25 @@ fn is_preflight_error(v: &Value) -> bool { /// (first event is an `is_error` result) or produces nothing. async fn attempt_spawn( worker: &Arc, - spec: SpawnSpec, + spec: Egg, content: &str, ) -> Option<(Cell, Value)> { - let cell = worker.spawn(spec).await.ok()?; - if cell.stdin.send(encode_prompt(content)).await.is_err() { - cell.cancel.cancel(); + let cell = worker.raise(spec).await.ok()?; + if cell.feed.send(encode_prompt(content)).await.is_err() { + cell.silence.cancel(); return None; } let first = { - let mut ev = cell.events.lock().await; + let mut ev = cell.mmm.lock().await; match tokio::time::timeout(std::time::Duration::from_secs(180), ev.recv()).await { Ok(Some(v)) => Some(v), _ => None, } }; match first { - Some(v) if is_preflight_error(&v) => { cell.cancel.cancel(); None } + Some(v) if is_preflight_error(&v) => { cell.silence.cancel(); None } Some(v) => Some((cell, v)), - None => { cell.cancel.cancel(); None } + None => { cell.silence.cancel(); None } } } @@ -399,7 +399,7 @@ async fn handle_prompt( metrics::gauge!("hum_cell_count").set(g.len() as f64); } - let mut base = SpawnSpec::new(sid.clone(), model.clone(), cwd); + let mut base = Egg::new(sid.clone(), model.clone(), cwd); base.system_prompt = system_prompt; base.mcp_url = Some(mcp_url); if let Some(arr) = tone.get("allowedTools").and_then(Value::as_array) { @@ -431,9 +431,9 @@ async fn handle_prompt( } } }; - let stdin = cell.stdin.clone(); - let events = cell.events.clone(); - let cancel = cell.cancel.clone(); + let stdin = cell.feed.clone(); + let events = cell.mmm.clone(); + let cancel = cell.silence.clone(); let finish_sent = Arc::new(AtomicBool::new(false)); let tool_use_blocks = Arc::new(Mutex::new(std::collections::BTreeSet::new())); let last_touched = Arc::new(AtomicU64::new(now_ms())); @@ -502,7 +502,7 @@ async fn handle_prompt( let sid_for_cleanup = sid.clone(); let finish_for_cleanup = finish_sent.clone(); tokio::spawn(async move { - let exit_code = cell.exited.await.unwrap_or(1); + let exit_code = cell.emerged.await.unwrap_or(1); let _ = dispatch.await; idle_task.abort(); if !finish_for_cleanup.load(Ordering::SeqCst) { diff --git a/nest/src/lib.rs b/nest/src/lib.rs index bf307af1..07c04a58 100644 --- a/nest/src/lib.rs +++ b/nest/src/lib.rs @@ -13,12 +13,9 @@ pub mod lifecycle; pub mod metrics; pub mod limits; -/// High-level spec the daemon hands to a worker bee. The bee is -/// responsible for turning this into whatever command line / process -/// invocation its underlying harness needs — CLI args, env vars, etc. -/// The daemon stays harness-agnostic. +/// An egg — what a worker bee needs to raise a cell. #[derive(Debug, Clone)] -pub struct SpawnSpec { +pub struct Egg { /// hum session id for this cell. pub sid: String, /// Model id to run on (e.g. "claude-sonnet-4-6", "claude-haiku-4-5"). @@ -58,10 +55,10 @@ pub struct SpawnSpec { /// OS-level caps the WorkerBee impl applies to the spawned child via /// `Command::pre_exec` (Linux) or no-op (other platforms). /// Default: empty — child inherits the parent's limits. - pub resource_limits: limits::ResourceLimits, + pub bounds: limits::Bounds, } -impl SpawnSpec { +impl Egg { pub fn new(sid: impl Into, model_id: impl Into, cwd: impl Into) -> Self { Self { sid: sid.into(), @@ -77,28 +74,24 @@ impl SpawnSpec { allowed_tools: Vec::new(), disallowed_tools: Vec::new(), env: HashMap::new(), - resource_limits: limits::ResourceLimits::default(), + bounds: limits::Bounds::default(), } } } -/// A Cell is one live subprocess seen from the daemon side. Pipe and -/// PTY worker bees both produce this same shape. +/// One brood cell — a living subprocess raised inside a bee. pub struct Cell { - pub pid: Option, - /// Send raw NDJSON lines (already serialized, no trailing newline) to the - /// child's stdin. Pipe-mode workers write them straight through; - /// PTY workers translate `{type:"user",...}` into typed text + Enter. - pub stdin: mpsc::Sender, - /// Parsed stream events. Each Value is one JSON message off - /// stdout. The daemon binary turns these into thrum petals. - pub events: Arc>>, - /// Resolves with the child's exit code once it terminates. - pub exited: tokio::sync::oneshot::Receiver, - /// True for PTY/REPL-style cells the pool evicts on each `result`. + pub mark: Option, + pub feed: mpsc::Sender, + pub mmm: Arc>>, + pub emerged: tokio::sync::oneshot::Receiver, pub ephemeral: bool, - /// `.cancel()` → SIGKILL + reap. Idempotent. - pub cancel: CancellationToken, + pub silence: CancellationToken, +} + +impl Cell { + /// Still the cell — SIGKILL + reap, idempotent. + pub fn still(&self) { self.silence.cancel(); } } /// Statefulness propensity of a bee — the same axis hives carry @@ -124,28 +117,21 @@ pub enum Propensity { EphemeralPerCall, } -/// A WorkerBee produces compute — it spawns a cell (subprocess or -/// in-process inference) when handed a `SpawnSpec`. This is the trait -/// any compute-side bee implements to be commissioned by a hive. +/// A WorkerBee raises cells from eggs — the compute-side trait every +/// commissioned hive implements. #[async_trait] pub trait WorkerBee: Send + Sync { fn ephemeral(&self) -> bool; - /// What kind of state machine does this worker run? Default - /// implementation is conservative (`EphemeralPerCall`) so any new - /// worker that forgets to override gets correct full-history - /// behavior at the cost of perf, not the other way around. fn propensity(&self) -> Propensity { if self.ephemeral() { Propensity::EphemeralPerCall } else { Propensity::StatefulSession } } - async fn spawn(&self, spec: SpawnSpec) -> Result; + async fn raise(&self, egg: Egg) -> Result; } -/// A non-text addition to a prompt — image, audio, pdf, etc. Carried -/// alongside the text content so workers can hand the model both at -/// once. `data` is base64 for inline; `url` is the alternative (worker -/// dereferences). Exactly one of `data` / `url` should be set. +/// Pollen — what a forager bee carries back alongside the text: +/// images, audio, pdf, video, files. #[derive(Debug, Clone)] -pub struct Attachment { +pub struct Pollen { /// Content category. "image" / "audio" / "pdf" / "video" / "file". /// Workers decide which kinds they can route to the model. pub kind: String, @@ -159,9 +145,9 @@ pub struct Attachment { /// Encode a user prompt for stream-json stdin. Wraps the text in the /// content-block shape stream-json workers expect. Use -/// `encode_prompt_with_attachments` for multimodal prompts. +/// `encode_prompt_with_pollen` for multimodal prompts. pub fn encode_prompt(text: &str) -> String { - encode_prompt_with_attachments(text, &[]) + encode_prompt_with_pollen(text, &[]) } /// Encode a user prompt with non-text attachments alongside. Image @@ -170,17 +156,17 @@ pub fn encode_prompt(text: &str) -> String { /// workers can opt-in as their model surface grows. Unknown kinds /// without a known encoding fall back to a text annotation so the /// model at least sees that an attachment was present. -pub fn encode_prompt_with_attachments(text: &str, attachments: &[Attachment]) -> String { +pub fn encode_prompt_with_pollen(text: &str, pollen: &[Pollen]) -> String { let mut content: Vec = vec![serde_json::json!({"type": "text", "text": text})]; - for att in attachments { - match att.kind.as_str() { + for grain in pollen { + match grain.kind.as_str() { "image" => { - if let Some(data) = att.data.as_ref() { + if let Some(data) = grain.data.as_ref() { content.push(serde_json::json!({ "type": "image", - "source": { "type": "base64", "media_type": att.media_type, "data": data } + "source": { "type": "base64", "media_type": grain.media_type, "data": data } })); - } else if let Some(url) = att.url.as_ref() { + } else if let Some(url) = grain.url.as_ref() { content.push(serde_json::json!({ "type": "image", "source": { "type": "url", "url": url } @@ -193,12 +179,12 @@ pub fn encode_prompt_with_attachments(text: &str, attachments: &[Attachment]) -> // rather than silently dropping. Workers that learn // to handle new kinds (audio, pdf) take over the // proper translation later. - let where_clause = att.url.as_deref() + let where_clause = grain.url.as_deref() .map(|u| format!(" ({u})")) .unwrap_or_default(); content.push(serde_json::json!({ "type": "text", - "text": format!("[attachment: kind={other} media_type={}{where_clause}]", att.media_type) + "text": format!("[attachment: kind={other} media_type={}{where_clause}]", grain.media_type) })); } } diff --git a/nest/src/lifecycle.rs b/nest/src/lifecycle.rs index 336af52a..1b6d517a 100644 --- a/nest/src/lifecycle.rs +++ b/nest/src/lifecycle.rs @@ -13,7 +13,7 @@ const REAP_TIMEOUT: Duration = Duration::from_secs(5); const SIGKILL_EXIT: i32 = 137; const SAMPLE_INTERVAL: Duration = Duration::from_secs(10); -pub fn supervise(mut child: AsyncGroupChild) -> (oneshot::Receiver, CancellationToken) { +pub fn tend(mut child: AsyncGroupChild) -> (oneshot::Receiver, CancellationToken) { let cancel = CancellationToken::new(); let cancel_for_task = cancel.clone(); let (tx_exit, rx_exit) = oneshot::channel(); @@ -88,7 +88,7 @@ mod tests { let child = Command::new("sleep").arg("60") .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()) .group_spawn().expect("spawn sleep"); - let (rx_exit, cancel) = supervise(child); + let (rx_exit, cancel) = tend(child); let start = Instant::now(); cancel.cancel(); let code = tokio::time::timeout(Duration::from_secs(REAP_TIMEOUT.as_secs() + 1), rx_exit) @@ -102,7 +102,7 @@ mod tests { let child = Command::new("sh").arg("-c").arg("exit 42") .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()) .group_spawn().expect("spawn sh"); - let (rx_exit, _cancel) = supervise(child); + let (rx_exit, _cancel) = tend(child); let code = tokio::time::timeout(Duration::from_secs(5), rx_exit) .await.expect("stuck").expect("dropped"); assert_eq!(code, 42); @@ -117,7 +117,7 @@ mod tests { let child = Command::new("sh").arg("-c").arg(&script) .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()) .group_spawn().expect("spawn sh"); - let (_rx, cancel) = supervise(child); + let (_rx, cancel) = tend(child); for _ in 0..30 { if marker.exists() { break; } tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/nest/src/limits.rs b/nest/src/limits.rs index 837c2700..781ead6e 100644 --- a/nest/src/limits.rs +++ b/nest/src/limits.rs @@ -1,6 +1,6 @@ //! Per-cell OS-level resource caps — RSS, fds, CPU shares, wall-clock TTL. //! -//! Defines `ResourceLimits` and an `apply_pre_exec()` helper that wires the +//! Defines `Bounds` and an `apply_pre_exec()` helper that wires the //! caps into a `std::process::Command` via `CommandExt::pre_exec`. On Linux //! we call `setrlimit(2)` / `setpriority(2)` in the post-fork pre-exec //! child. On non-Linux the apply is a no-op so callers compile and degrade @@ -20,7 +20,7 @@ use serde::{Deserialize, Serialize}; /// Applied via rlimit (setrlimit) before exec on Linux. On non-Linux /// platforms `apply_pre_exec` is a no-op — caps degrade gracefully. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct ResourceLimits { +pub struct Bounds { /// Hard cap on resident set size in bytes. Maps to RLIMIT_AS on Linux. /// Going past this gets the child OOM-killed by the kernel. pub rss_bytes: Option, @@ -38,7 +38,7 @@ pub struct ResourceLimits { pub nice: Option, } -impl ResourceLimits { +impl Bounds { /// Wire the limits into a `std::process::Command` so the child /// inherits them at exec time. Linux-only enforcement; on other /// platforms returns Ok(()) without doing anything. @@ -100,7 +100,7 @@ impl ResourceLimits { /// Post-fork, pre-exec child body. Must only touch async-signal-safe APIs. #[cfg(target_os = "linux")] -fn apply_in_child(limits: &ResourceLimits) -> std::io::Result<()> { +fn apply_in_child(limits: &Bounds) -> std::io::Result<()> { if let Some(rss) = limits.rss_bytes { set_rlimit(libc::RLIMIT_AS, rss)?; } @@ -153,7 +153,7 @@ mod tests { #[test] fn default_is_empty() { - let d = ResourceLimits::default(); + let d = Bounds::default(); assert!(d.rss_bytes.is_none()); assert!(d.fd_count.is_none()); assert!(d.cpu_secs.is_none()); @@ -163,20 +163,20 @@ mod tests { #[test] fn tight_has_rss_cap() { - let t = ResourceLimits::tight(); + let t = Bounds::tight(); assert!(t.rss_bytes.is_some()); } #[test] fn generous_has_no_wall_clock() { - assert!(ResourceLimits::generous().wall_clock_ms.is_none()); + assert!(Bounds::generous().wall_clock_ms.is_none()); } #[test] fn serialization_roundtrip() { - let original = ResourceLimits::tight(); + let original = Bounds::tight(); let json = serde_json::to_string(&original).expect("serialize"); - let back: ResourceLimits = serde_json::from_str(&json).expect("deserialize"); + let back: Bounds = serde_json::from_str(&json).expect("deserialize"); assert_eq!(original, back); } @@ -186,7 +186,7 @@ mod tests { // wiring it up succeeds — the closure body runs in the child at // spawn time, which we deliberately skip to avoid polluting the // test process tree. - let limits = ResourceLimits::default(); + let limits = Bounds::default(); let mut cmd = Command::new("/bin/true"); assert!(limits.apply_pre_exec(&mut cmd).is_ok()); } diff --git a/nest/src/metrics.rs b/nest/src/metrics.rs index 77a2f426..ec1827e6 100644 --- a/nest/src/metrics.rs +++ b/nest/src/metrics.rs @@ -7,7 +7,7 @@ use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System}; use tokio::sync::mpsc; #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CellMetrics { +pub struct Vitals { pub pid: Option, pub rss_bytes: Option, pub cpu_ms: Option, @@ -16,10 +16,10 @@ pub struct CellMetrics { pub sampled_at_ms: i64, } -pub fn sample(pid: u32, spawned_at_ms: i64) -> CellMetrics { +pub fn sample(pid: u32, spawned_at_ms: i64) -> Vitals { let sampled_at_ms = now_ms(); let age_ms = sampled_at_ms.saturating_sub(spawned_at_ms).max(0) as u64; - let mut m = CellMetrics { + let mut m = Vitals { pid: Some(pid), age_ms, sampled_at_ms, @@ -47,7 +47,7 @@ pub fn spawn_sampler( pid: u32, spawned_at_ms: i64, interval: std::time::Duration, -) -> (mpsc::UnboundedReceiver, tokio::task::JoinHandle<()>) { +) -> (mpsc::UnboundedReceiver, tokio::task::JoinHandle<()>) { let (tx, rx) = mpsc::unbounded_channel(); let handle = tokio::spawn(async move { let mut tick = tokio::time::interval(interval); From febbc00e8a563a8141d7bb90fee8747e18ad1cfc Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sun, 31 May 2026 08:04:29 +0000 Subject: [PATCH 13/18] HumId: canonical 256-bit identifier across the codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ids/HumId newtype replaces stringly-typed identifiers everywhere hum mints them. Foreign formats are projections via deterministic transforms. ids crate - HumId(String) newtype with Serialize/Deserialize/Display/FromStr/AsRef - HumId::mint() — ts-prefixed random; sessions, requests, calls - HumId::from_hash([u8; 32]) — pure hash encoding - HumId::from_foreign(&str) — deterministic projection; same input → same id - to_uuid_v5(NS_CLAUDE_SESSION) — outgoing UUID for claude --session-id - NS_CLAUDE_SESSION preserves the historical HUM_SESSION_NS bytes so existing claude transcripts stay reachable Type signature changes (internal) - nest::Egg.sid: HumId (was String) - nest::Egg.session_id REMOVED (derived from sid.to_uuid_v5 inside claude-cli) - nest::Egg.fresh: bool ADDED (resume vs --session-id flag for the worker) Mint sites canonicalized - thrumd connection id (cid) — HumId::mint() (was uuid::Uuid::new_v4) - thrum_core::rid() returns HumId-format (was tsBase36-counterBase36) - hives/common/mcp_bridge call_id — thrum_core::rid() (no more 'call-' prefix) - all hive hello rids: bp7, grpc, gsm-modem, paid-oracle, ollama-server, ensemble - ollama-server per-request sid — HumId::mint() - all sim test cid/rid fixtures Boundary - hives/common/serve.rs canonicalizes incoming wire sid: HumId::parse(&s).unwrap_or_else(|_| HumId::from_foreign(&s)) - dead HUM_SESSION_NS + sid_to_session helper removed hum-paths bonus - config::denied() uses hum_paths::config_dir() instead of '~/.config/hum' literal (now correct under XDG_CONFIG_HOME override) - hum CLI module doc adds 'hum nest' + 'hum update'; drops stale 'Inspection-only for 0.3'; fixes bees.json comment Untouched (by design) - Hid (humd/bee identity, sha256(pubkey) hex with role prefix) — different beast - Wire types stay String; humd canonicalizes at entry handlers - kad query rids (derived from query_id for cross-hop correlation) - Cryptographic nonce in paid-oracle --- config/src/lib.rs | 13 ++- ensemble/Cargo.toml | 1 + ensemble/src/lib.rs | 4 +- hives/bp7/Cargo.toml | 1 + hives/bp7/src/main.rs | 2 +- hives/claude-cli/Cargo.toml | 1 + hives/claude-cli/src/lib.rs | 31 ++++--- hives/common/Cargo.toml | 1 + hives/common/src/mcp_bridge.rs | 2 +- hives/common/src/serve.rs | 34 ++------ hives/grpc/Cargo.toml | 1 + hives/grpc/src/main.rs | 5 +- hives/gsm-modem/Cargo.toml | 1 + hives/gsm-modem/src/main.rs | 4 +- hives/ollama-server/Cargo.toml | 2 +- hives/ollama-server/src/main.rs | 7 +- hives/paid-oracle/Cargo.toml | 1 + hives/paid-oracle/src/main.rs | 2 +- hum/src/main.rs | 10 +-- ids/Cargo.toml | 6 ++ ids/src/lib.rs | 140 +++++++++++++++++++++++++++++++- nest/Cargo.toml | 1 + nest/src/lib.rs | 26 +++--- sim/Cargo.toml | 2 +- sim/src/lib.rs | 10 +-- sim/tests/eggs_on_the_hum.rs | 2 +- sim/tests/tool_catalogue.rs | 4 +- thrum-core/Cargo.toml | 1 + thrum-core/src/prims.rs | 33 ++------ thrumd/Cargo.toml | 2 +- thrumd/src/conn.rs | 4 +- 31 files changed, 232 insertions(+), 122 deletions(-) diff --git a/config/src/lib.rs b/config/src/lib.rs index 2cc53037..e5ac99c2 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -288,15 +288,12 @@ mod defaults { "claude-repl".into() } pub fn denied() -> Vec { - [ - "~/.ssh", - "~/.aws", - "~/.gnupg", - "~/.config/hum", + vec![ + PathBuf::from("~/.ssh"), + PathBuf::from("~/.aws"), + PathBuf::from("~/.gnupg"), + hum_paths::config_dir(), ] - .iter() - .map(PathBuf::from) - .collect() } } diff --git a/ensemble/Cargo.toml b/ensemble/Cargo.toml index 947a4cfd..9632de77 100644 --- a/ensemble/Cargo.toml +++ b/ensemble/Cargo.toml @@ -7,6 +7,7 @@ description = "hum ensemble — the mesh of humds. Peer identity, transport trai [dependencies] thrum-core = { path = "../thrum-core" } +ids = { path = "../ids" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/ensemble/src/lib.rs b/ensemble/src/lib.rs index 3033c1f8..8d9adf6d 100644 --- a/ensemble/src/lib.rs +++ b/ensemble/src/lib.rs @@ -389,7 +389,7 @@ pub enum HelloParse { pub fn hello_tone_unsigned(me: &Hid, caps: &PeerCapabilities) -> Tone { serde_json::json!({ "chi": "hello", - "rid": format!("hello-{}", me.short()), + "rid": ids::HumId::mint().to_string(), "from": me.to_hex(), "humd_id": me.to_hex(), "proto_version": caps.proto_version, @@ -414,7 +414,7 @@ pub fn hello_tone(me: &Hid, key: &HumdKey, caps: &PeerCapabilities) -> Tone { let sig: Signature = key.0.sign(&msg); serde_json::json!({ "chi": "hello", - "rid": format!("hello-{}", me.short()), + "rid": ids::HumId::mint().to_string(), "from": me.to_hex(), "humd_id": me.to_hex(), "pubkey": hex::encode(key.pubkey_bytes()), diff --git a/hives/bp7/Cargo.toml b/hives/bp7/Cargo.toml index 1665f32e..44f2bd0f 100644 --- a/hives/bp7/Cargo.toml +++ b/hives/bp7/Cargo.toml @@ -7,6 +7,7 @@ description = "RFC 9171 Bundle Protocol v7 forager hive — interplanetary store [dependencies] thrum-core = { path = "../../thrum-core" } +ids = { path = "../../ids" } nest-common = { path = "../common" } ensemble = { path = "../../ensemble" } tokio = { workspace = true } diff --git a/hives/bp7/src/main.rs b/hives/bp7/src/main.rs index 985f8c05..31dcbf50 100644 --- a/hives/bp7/src/main.rs +++ b/hives/bp7/src/main.rs @@ -173,7 +173,7 @@ async fn run_prompt( let hello = json!({ "chi": Chi::Hello, - "rid": format!("hello-{}", now_ms()), + "rid": ids::HumId::mint().to_string(), "from": HIVE_NAME, "hid": hid, "bee": ["forager"], diff --git a/hives/claude-cli/Cargo.toml b/hives/claude-cli/Cargo.toml index efe90e3d..cacf43c2 100644 --- a/hives/claude-cli/Cargo.toml +++ b/hives/claude-cli/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" hum-paths = { path = "../../hum-paths" } nest = { path = "../../nest" } nest-common = { path = "../common" } +ids = { path = "../../ids" } tokio = { workspace = true, features = ["full"] } command-group = { version = "5", features = ["with-tokio"] } serde_json = { workspace = true } diff --git a/hives/claude-cli/src/lib.rs b/hives/claude-cli/src/lib.rs index 0985cf5f..0c7a2030 100644 --- a/hives/claude-cli/src/lib.rs +++ b/hives/claude-cli/src/lib.rs @@ -65,14 +65,18 @@ pub fn build_argv(spec: &Egg) -> Vec { argv.push("--system-prompt".into()); argv.push(sp.to_string()); } - // resume an existing session, or create one under an explicit id. - // resume wins; the two are mutually exclusive on claude's CLI. + // Foreign explicit resume wins. Otherwise: --resume the sid-derived + // UUID (warm continuation), or --session-id it (fresh after resume miss). + let derived = spec.sid.to_uuid_v5(ids::NS_CLAUDE_SESSION).to_string(); if let Some(resume) = spec.resume_id.as_deref() { argv.push("--resume".into()); argv.push(resume.to_string()); - } else if let Some(session) = spec.session_id.as_deref() { + } else if spec.fresh { argv.push("--session-id".into()); - argv.push(session.to_string()); + argv.push(derived); + } else { + argv.push("--resume".into()); + argv.push(derived); } argv } @@ -219,7 +223,7 @@ mod tests { #[test] fn argv_includes_basics() { - let spec = Egg::new("sid-1", "claude-haiku-4-5", "/tmp"); + let spec = Egg::new(ids::HumId::mint(), "claude-haiku-4-5", "/tmp"); let argv = build_argv(&spec); assert!(argv.contains(&"-p".to_string())); assert!(argv.contains(&"--verbose".to_string())); @@ -230,7 +234,7 @@ mod tests { #[test] fn argv_omits_mcp_when_no_url() { - let spec = Egg::new("s", "m", "/"); + let spec = Egg::new(ids::HumId::mint(), "m", "/"); let argv = build_argv(&spec); assert!(!argv.iter().any(|a| a == "--mcp-config")); assert!(!argv.iter().any(|a| a == "--strict-mcp-config")); @@ -238,21 +242,22 @@ mod tests { #[test] fn argv_includes_mcp_when_url_set() { - let mut spec = Egg::new("sid-9", "m", "/"); + let sid = ids::HumId::mint(); + let mut spec = Egg::new(sid.clone(), "m", "/"); spec.mcp_url = Some("http://127.0.0.1:29147".into()); let argv = build_argv(&spec); let idx = argv.iter().position(|a| a == "--mcp-config").expect("mcp-config flag"); let config: serde_json::Value = serde_json::from_str(&argv[idx + 1]).unwrap(); assert_eq!( config["mcpServers"]["hum"]["url"], - "http://127.0.0.1:29147/s/sid-9" + format!("http://127.0.0.1:29147/s/{sid}") ); assert!(argv.iter().any(|a| a == "--strict-mcp-config")); } #[test] fn argv_includes_system_prompt() { - let mut spec = Egg::new("s", "m", "/"); + let mut spec = Egg::new(ids::HumId::mint(), "m", "/"); spec.system_prompt = Some("Be terse.".into()); let argv = build_argv(&spec); let i = argv.iter().position(|a| a == "--system-prompt").unwrap(); @@ -261,7 +266,7 @@ mod tests { #[test] fn argv_includes_resume() { - let mut spec = Egg::new("s", "m", "/"); + let mut spec = Egg::new(ids::HumId::mint(), "m", "/"); spec.resume_id = Some("abc-123".into()); let argv = build_argv(&spec); let i = argv.iter().position(|a| a == "--resume").unwrap(); @@ -270,14 +275,14 @@ mod tests { #[test] fn env_disables_adaptive_thinking_when_not_planning() { - let spec = Egg::new("s", "m", "/"); + let spec = Egg::new(ids::HumId::mint(), "m", "/"); let env = build_env(&spec); assert!(env.iter().any(|(k, v)| k == "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING" && v == "1")); } #[test] fn env_keeps_adaptive_thinking_in_plan_mode() { - let mut spec = Egg::new("s", "m", "/"); + let mut spec = Egg::new(ids::HumId::mint(), "m", "/"); spec.plan_mode = true; let env = build_env(&spec); assert!(!env.iter().any(|(k, _)| k == "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING")); @@ -285,7 +290,7 @@ mod tests { #[test] fn env_user_override_wins() { - let mut spec = Egg::new("s", "m", "/"); + let mut spec = Egg::new(ids::HumId::mint(), "m", "/"); spec.env.insert("CLAUDE_CODE_DISABLE_FAST_MODE".into(), "0".into()); let env = build_env(&spec); let positions: Vec<(usize, &str)> = env.iter().enumerate() diff --git a/hives/common/Cargo.toml b/hives/common/Cargo.toml index 0f04ae4a..8494ca91 100644 --- a/hives/common/Cargo.toml +++ b/hives/common/Cargo.toml @@ -11,6 +11,7 @@ drone = { path = "../../drone" } ensemble = { path = "../../ensemble" } mcp = { path = "../../mcp" } nest = { path = "../../nest" } +ids = { path = "../../ids" } thrum-core = { path = "../../thrum-core" } async-trait = "0.1" axum = "0.7" diff --git a/hives/common/src/mcp_bridge.rs b/hives/common/src/mcp_bridge.rs index 895b72bd..b9e76c34 100644 --- a/hives/common/src/mcp_bridge.rs +++ b/hives/common/src/mcp_bridge.rs @@ -254,7 +254,7 @@ async fn handle( )))); } let arguments = params.get("arguments").cloned().unwrap_or(serde_json::json!({})); - let call_id = format!("call-{}", thrum_core::rid()); + let call_id = thrum_core::rid(); let (tx, rx) = oneshot::channel::(); bridge.pending.lock().insert(call_id.clone(), tx); let tone = translate::mcp_call_to_tone(&sid, &call_id, ¶ms); diff --git a/hives/common/src/serve.rs b/hives/common/src/serve.rs index ca17c2ff..2437e92d 100644 --- a/hives/common/src/serve.rs +++ b/hives/common/src/serve.rs @@ -287,20 +287,6 @@ const IDLE_TIMEOUT_MS: u64 = 300_000; /// when full. const MAX_CELLS: usize = 8; -/// Fixed namespace so a hum sid maps to a stable claude session UUID -/// (uuid5). Deterministic: the same sid always derives the same id, so -/// it survives worker restarts without persisting anything. -const HUM_SESSION_NS: uuid::Uuid = uuid::Uuid::from_bytes([ - 0x68, 0x75, 0x6d, 0x2d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2d, 0x6e, 0x73, 0x00, 0x01, -]); - -/// Derive the claude session id for a hum sid. claude's `--session-id` -/// requires a UUID, so we can't pass the sid verbatim; uuid5 maps it -/// deterministically. -fn sid_to_session(sid: &str) -> String { - uuid::Uuid::new_v5(&HUM_SESSION_NS, sid.as_bytes()).to_string() -} - /// True if `v` is claude's terminal `result` event flagged `is_error`. /// As the *first* event it means a pre-flight failure (bad/absent /// session on `--resume`, id clash on `--session-id`) rather than a @@ -399,7 +385,8 @@ async fn handle_prompt( metrics::gauge!("hum_cell_count").set(g.len() as f64); } - let mut base = Egg::new(sid.clone(), model.clone(), cwd); + let hum_sid = ids::HumId::parse(&sid).unwrap_or_else(|_| ids::HumId::from_foreign(&sid)); + let mut base = Egg::new(hum_sid, model.clone(), cwd); base.system_prompt = system_prompt; base.mcp_url = Some(mcp_url); if let Some(arr) = tone.get("allowedTools").and_then(Value::as_array) { @@ -409,23 +396,20 @@ async fn handle_prompt( base.disallowed_tools = arr.iter().filter_map(Value::as_str).map(str::to_string).collect(); } - // A hum sid is one conversation. `claude -p` exits after each turn, - // so the warm cell is gone before the next (tick-spaced) prompt; to - // keep continuity we bind the sid to a deterministic claude session - // (uuid5 of the sid) and resume it. Resume-first so the common case - // (an ongoing sid) is one spawn; on the turn where the session does - // not exist yet, claude's `--resume` fails fast (a `result` is_error - // with no output), and we fall back to `--session-id` to create it. - let cid = sid_to_session(&sid); + // Resume-first: claude `-p` exits after each turn, so the warm cell + // is gone before the next prompt. The sid-derived session UUID + // (computed inside claude-cli from base.sid) keeps continuity. On + // first turn `--resume` fails fast (a `result` is_error with no + // output) and we retry with `fresh: true` → --session-id. let (cell, first_event) = { let mut s1 = base.clone(); - s1.resume_id = Some(explicit_resume.clone().unwrap_or_else(|| cid.clone())); + s1.resume_id = explicit_resume.clone(); match attempt_spawn(&worker, s1, &content).await { Some(pair) => pair, None => { trace!(sid = %sid, "worker.resume.miss.creating"); let mut s2 = base.clone(); - s2.session_id = Some(cid.clone()); // resume_id None -> --session-id + s2.fresh = true; attempt_spawn(&worker, s2, &content).await .ok_or_else(|| anyhow::anyhow!("spawn failed (resume and create both)"))? } diff --git a/hives/grpc/Cargo.toml b/hives/grpc/Cargo.toml index bce33026..a0886cc1 100644 --- a/hives/grpc/Cargo.toml +++ b/hives/grpc/Cargo.toml @@ -7,6 +7,7 @@ description = "gRPC bridge to hum — bidi stream, every chi flows through; tran [dependencies] thrum-core = { path = "../../thrum-core" } +ids = { path = "../../ids" } nest-common = { path = "../common" } ensemble = { path = "../../ensemble" } tokio = { workspace = true } diff --git a/hives/grpc/src/main.rs b/hives/grpc/src/main.rs index c2d3f9ee..60a8b9d0 100644 --- a/hives/grpc/src/main.rs +++ b/hives/grpc/src/main.rs @@ -52,10 +52,7 @@ async fn bridge( // Send hello on connect so humd advertises us to the mesh. let hello = serde_json::json!({ "chi": Chi::Hello, - "rid": format!("hello-{}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis().to_string()) - .unwrap_or_default()), + "rid": ids::HumId::mint().to_string(), "from": HIVE_NAME, "hid": hid, "bee": ["forager"], diff --git a/hives/gsm-modem/Cargo.toml b/hives/gsm-modem/Cargo.toml index 334dff56..d74c2809 100644 --- a/hives/gsm-modem/Cargo.toml +++ b/hives/gsm-modem/Cargo.toml @@ -7,6 +7,7 @@ description = "Hum-on-a-burner-phone — USB GSM modem bee driven by AT commands [dependencies] thrum-core = { path = "../../thrum-core" } +ids = { path = "../../ids" } nest-common = { path = "../common" } ensemble = { path = "../../ensemble" } tokio = { workspace = true } diff --git a/hives/gsm-modem/src/main.rs b/hives/gsm-modem/src/main.rs index 2aa339f3..7f74fad6 100644 --- a/hives/gsm-modem/src/main.rs +++ b/hives/gsm-modem/src/main.rs @@ -122,7 +122,7 @@ async fn handle_sms(cfg: Arc, sms: IncomingSms, writer: Arc, sms: IncomingSms, writer: Arc, ) -> Response { let stream = req.stream.unwrap_or(true); - let sid = Uuid::new_v4().to_string(); + let sid = ids::HumId::mint().to_string(); let (rx, _pump) = match open_prompt( &cfg, &sid, &req.prompt, &req.model, req.system.as_deref(), None, ).await { diff --git a/hives/paid-oracle/Cargo.toml b/hives/paid-oracle/Cargo.toml index c8bd9521..48a81543 100644 --- a/hives/paid-oracle/Cargo.toml +++ b/hives/paid-oracle/Cargo.toml @@ -7,6 +7,7 @@ description = "x402-style paid oracle bee — sells one price per USDC payment, [dependencies] thrum-core = { path = "../../thrum-core" } +ids = { path = "../../ids" } nest-common = { path = "../common" } ensemble = { path = "../../ensemble" } tokio = { workspace = true } diff --git a/hives/paid-oracle/src/main.rs b/hives/paid-oracle/src/main.rs index 8dc02569..d3a74ac2 100644 --- a/hives/paid-oracle/src/main.rs +++ b/hives/paid-oracle/src/main.rs @@ -207,7 +207,7 @@ async fn main() -> Result<()> { fn hello(cfg: &Config, hid: &str) -> Value { json!({ "chi": Chi::Hello, - "rid": format!("hello-{}", uuid::Uuid::new_v4()), + "rid": ids::HumId::mint().to_string(), "from": HIVE_NAME, "hid": hid, "bee": ["forager"], diff --git a/hum/src/main.rs b/hum/src/main.rs index d1f35551..dd203819 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -1,11 +1,5 @@ //! `hum` — main user-facing CLI. //! -//! Inspection-only for 0.3: every subcommand reads cross-platform -//! state (filesystem + service manager via scripts/svc.sh). Daemon- -//! internal queries (peers, drift, drone, sessions) will land when -//! humd exposes an RPC control socket; until then, those live as -//! `humd ` arguments inside the daemon binary's own CLI. -//! //! Subcommands: //! hum health summary //! hum status daemon + config + service state @@ -15,8 +9,10 @@ //! hum hive install build a hive + register its bee //! hum bee --list list bees + state //! hum bee VERB enter | exit | reenter a bee (start/stop/restart) +//! hum nest list orchd-managed bees (delegates to `orchd status`) //! hum penny show lifetime counters //! hum recipes [name] list recipes / point at one +//! hum update self-update from latest GitHub release //! hum uninstall remove service + binary (state preserved) //! hum version print version //! hum help print this surface @@ -805,7 +801,7 @@ fn bee(target: Option, verb: Option, list: bool) -> Result<()> { let installed = bee_list(&svc)?; // List: `hum bee --list`, or bare `hum bee`. Full info comes from - // humd's live manifest snapshot ($XDG_STATE_HOME/hum/bees.json); + // humd's live manifest snapshot (`hum_paths::bees_snapshot()`); // service state comes from the service manager. if list || (target.is_none() && verb.is_none()) { bee_list_full(&svc, &installed)?; diff --git a/ids/Cargo.toml b/ids/Cargo.toml index 897c3037..836beae2 100644 --- a/ids/Cargo.toml +++ b/ids/Cargo.toml @@ -7,3 +7,9 @@ description = "hum-native 256-bit Crockford-base32 identifiers: 48-bit ms timest [dependencies] rand = { workspace = true } +serde = { workspace = true, features = ["derive"] } +sha2 = { workspace = true } +uuid = { version = "1", features = ["v5"] } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/ids/src/lib.rs b/ids/src/lib.rs index 4a0d72fd..fc71c097 100644 --- a/ids/src/lib.rs +++ b/ids/src/lib.rs @@ -1,4 +1,13 @@ -//! hum-native ID — 256-bit identifier, Crockford-base32 encoded. +//! HumId — the one canonical identifier hum mints. +//! +//! 256-bit, Crockford-base32 encoded (52 chars). Foreign formats are +//! projections via deterministic transform (`to_uuid_v5`). +//! +//! Three flavors, indistinguishable at the type level: +//! - `HumId::mint()` — ts-prefixed random (sessions, requests, calls) +//! - `HumId::from_hash(b)` — pure hash encoding (identity-derived) +//! - `HumId::from_foreign(s)` — deterministic hash of a foreign id +//! (e.g. OC plugin's uuid → stable HumId without a bridge map) //! //! Layout: `[ ts (6 bytes, BE) ][ random (26 bytes) ] = 32 bytes` //! Text: 52 chars Crockford base32 (260 bits → 4-bit zero pad on LSB end). @@ -9,7 +18,100 @@ //! - alphabet omits I, L, O, U (Crockford); uppercase only use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +/// UUID namespaces for deterministic foreign-format projections. +/// Constants — don't change them, ever; existing IDs depend on these bytes. +/// `NS_CLAUDE_SESSION` preserves the historical `HUM_SESSION_NS` value so +/// existing claude transcripts stay reachable after the canonical rewrite. +pub const NS_CLAUDE_SESSION: Uuid = Uuid::from_bytes([ + 0x68, 0x75, 0x6d, 0x2d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2d, 0x6e, 0x73, 0x00, 0x01, +]); + +/// Canonical hum identifier. Newtype around a validated 52-char string. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct HumId(String); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IdError { + BadFormat, +} + +impl fmt::Display for IdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { Self::BadFormat => write!(f, "not a canonical HumId (52-char Crockford-base32)") } + } +} + +impl std::error::Error for IdError {} + +impl HumId { + /// Fresh id, ts-prefixed. Use for sessions, requests, calls. + pub fn mint() -> Self { Self(mint_id()) } + + /// Encode a 32-byte hash. Use for identity-derived ids. + pub fn from_hash(bytes: [u8; 32]) -> Self { Self(encode(&bytes)) } + + /// Deterministic projection of a foreign string into HumId space. + /// Same input → same output, forever. No bridge map needed. + pub fn from_foreign(s: &str) -> Self { + let mut hasher = Sha256::new(); + hasher.update(b"hum-foreign-id:"); + hasher.update(s.as_bytes()); + let digest: [u8; 32] = hasher.finalize().into(); + Self(encode(&digest)) + } + + pub fn parse(s: &str) -> Result { + if is_valid_id(s) { Ok(Self(s.to_string())) } else { Err(IdError::BadFormat) } + } + + pub fn timestamp(&self) -> Option { timestamp_of(&self.0) } + + /// Deterministic UUIDv5 projection — used at boundaries that require + /// UUID shape (claude `--session-id`, etc). + pub fn to_uuid_v5(&self, namespace: Uuid) -> Uuid { + Uuid::new_v5(&namespace, self.0.as_bytes()) + } + + pub fn as_str(&self) -> &str { &self.0 } + pub fn into_string(self) -> String { self.0 } +} + +impl fmt::Display for HumId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } +} + +impl AsRef for HumId { + fn as_ref(&self) -> &str { &self.0 } +} + +impl FromStr for HumId { + type Err = IdError; + fn from_str(s: &str) -> Result { Self::parse(s) } +} + +impl From for String { + fn from(h: HumId) -> String { h.0 } +} + +impl Serialize for HumId { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for HumId { + fn deserialize>(d: D) -> Result { + let s = String::deserialize(d)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const ID_LEN: usize = 52; @@ -169,6 +271,42 @@ mod tests { ); } + #[test] + fn hum_id_roundtrip_mint() { + let h = HumId::mint(); + let s = h.as_str().to_string(); + assert_eq!(HumId::parse(&s).unwrap(), h); + assert!(h.timestamp().is_some()); + } + + #[test] + fn hum_id_from_foreign_is_deterministic() { + let a = HumId::from_foreign("oc-session-abc"); + let b = HumId::from_foreign("oc-session-abc"); + let c = HumId::from_foreign("oc-session-xyz"); + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn hum_id_to_uuid_v5_deterministic() { + let h = HumId::parse(&mint_id()).unwrap(); + let u1 = h.to_uuid_v5(NS_CLAUDE_SESSION); + let u2 = h.to_uuid_v5(NS_CLAUDE_SESSION); + assert_eq!(u1, u2); + assert_eq!(u1.get_version_num(), 5); + } + + #[test] + fn hum_id_serde_roundtrips() { + let h = HumId::mint(); + let j = serde_json::to_string(&h).unwrap(); + let back: HumId = serde_json::from_str(&j).unwrap(); + assert_eq!(back, h); + let bad: Result = serde_json::from_str("\"not-a-real-id\""); + assert!(bad.is_err()); + } + #[test] fn rejects_bad_chars_and_lengths() { assert!(!is_valid_id("")); diff --git a/nest/Cargo.toml b/nest/Cargo.toml index 05cbd460..1b10aa7a 100644 --- a/nest/Cargo.toml +++ b/nest/Cargo.toml @@ -20,6 +20,7 @@ tracing = { workspace = true } async-trait = { workspace = true } parking_lot = { workspace = true } portable-pty = "0.9" +ids = { path = "../ids" } [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2" diff --git a/nest/src/lib.rs b/nest/src/lib.rs index 07c04a58..e7f3980f 100644 --- a/nest/src/lib.rs +++ b/nest/src/lib.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; +use ids::HumId; use serde_json::Value; use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; @@ -16,8 +17,9 @@ pub mod limits; /// An egg — what a worker bee needs to raise a cell. #[derive(Debug, Clone)] pub struct Egg { - /// hum session id for this cell. - pub sid: String, + /// Canonical hum session id. Foreign-format projections (claude + /// `--session-id`) derive from this via `sid.to_uuid_v5(NS_*)`. + pub sid: HumId, /// Model id to run on (e.g. "claude-sonnet-4-6", "claude-haiku-4-5"). pub model_id: String, /// Working directory for the spawned process. Drives transcript @@ -31,14 +33,14 @@ pub struct Egg { pub mcp_url: Option, /// Optional path to the claude CLI binary. None → "claude" on PATH. pub cli_path: Option, - /// Optional resume id — the harness picks up an existing transcript - /// (claude `--resume`) instead of starting fresh. + /// Foreign resume id — a claude session UUID the harness should pick + /// up instead of the sid-derived one. Stays String because it's a + /// foreign identifier hum did not mint. pub resume_id: Option, - /// Optional explicit session id to create the conversation under - /// (claude `--session-id`, must be a UUID). Used when `resume_id` is - /// None to bind a fresh session to a deterministic id. Ignored if - /// `resume_id` is set. - pub session_id: Option, + /// True = create a new claude session under the sid-derived UUID. + /// False = try to resume that UUID. Worker flips to true on resume + /// miss. Ignored when `resume_id` is set. + pub fresh: bool, /// Plan mode — disables adaptive-thinking env. pub plan_mode: bool, /// Permissions allowlist names — passed to the harness's tool filter. @@ -59,16 +61,16 @@ pub struct Egg { } impl Egg { - pub fn new(sid: impl Into, model_id: impl Into, cwd: impl Into) -> Self { + pub fn new(sid: HumId, model_id: impl Into, cwd: impl Into) -> Self { Self { - sid: sid.into(), + sid, model_id: model_id.into(), cwd: cwd.into(), system_prompt: None, mcp_url: None, cli_path: None, resume_id: None, - session_id: None, + fresh: false, plan_mode: false, permissions: Vec::new(), allowed_tools: Vec::new(), diff --git a/sim/Cargo.toml b/sim/Cargo.toml index ab6aee3f..8032b0ed 100644 --- a/sim/Cargo.toml +++ b/sim/Cargo.toml @@ -11,13 +11,13 @@ humd = { path = "../humd" } thrum-core = { path = "../thrum-core" } thrumd = { path = "../thrumd" } nest = { path = "../nest" } +ids = { path = "../ids" } config = { path = "../config" } tokio = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } parking_lot = "0.12" -uuid = { version = "1", features = ["v4"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/sim/src/lib.rs b/sim/src/lib.rs index aad66c71..16e40d87 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -486,7 +486,7 @@ impl Sim { } let tone = serde_json::json!({ "chi": "wane-sync", - "rid": format!("wane-sync-{}", uuid::Uuid::new_v4()), + "rid": ids::HumId::mint().to_string(), "from": from.id.to_hex(), "to": to.id.to_hex(), "snapshot": Value::Object(snapshot_json), @@ -519,7 +519,7 @@ impl Sim { if h.thrum.has_sink() { break; } tokio::time::sleep(Duration::from_millis(5)).await; } - let client_id = format!("sim-worker-{}", uuid::Uuid::new_v4()); + let client_id = ids::HumId::mint().to_string(); let mut rx = h.thrum.register_synthetic(client_id.clone()); // Hello first so humd records bee:["worker"] + models before the // first prompt arrives. @@ -577,7 +577,7 @@ impl Sim { .get(&humd) .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", humd.short()))?; - let client_id = format!("sim-{}", uuid::Uuid::new_v4()); + let client_id = ids::HumId::mint().to_string(); let mut rx = h.thrum.register_synthetic(client_id.clone()); // Fanout task: drain this synthetic's outbound queue and route @@ -714,7 +714,7 @@ impl Sim { if h.thrum.has_sink() { break; } tokio::time::sleep(Duration::from_millis(5)).await; } - let client_id = format!("sim-forager-{}", uuid::Uuid::new_v4()); + let client_id = ids::HumId::mint().to_string(); let mut rx = h.thrum.register_synthetic(client_id.clone()); let tools: Vec = tool_names.iter().map(|name| serde_json::json!({ "name": name, @@ -773,7 +773,7 @@ impl Sim { observer_humd, serde_json::json!({ "chi": "attach", - "rid": format!("attach-{}", uuid::Uuid::new_v4()), + "rid": ids::HumId::mint().to_string(), "sid": sid, "to": host_humd.to_hex(), "from": observer_humd.to_hex(), diff --git a/sim/tests/eggs_on_the_hum.rs b/sim/tests/eggs_on_the_hum.rs index 7d85a61b..2e28ad63 100644 --- a/sim/tests/eggs_on_the_hum.rs +++ b/sim/tests/eggs_on_the_hum.rs @@ -56,7 +56,7 @@ async fn eggs_on_the_hum() { // Mock worker on the SERVER. Emits one chi:tool-call after // receiving the prompt, then chi:finish after the tool-result. let server_thrum = server.thrum.clone(); - let worker_cid = format!("sim-worker-{}", uuid::Uuid::new_v4()); + let worker_cid = ids::HumId::mint().to_string(); let mut worker_rx = server.thrum.register_synthetic(worker_cid.clone()); let worker_hello = serde_json::json!({ "chi": "hello", diff --git a/sim/tests/tool_catalogue.rs b/sim/tests/tool_catalogue.rs index 44d0638b..b3d54be9 100644 --- a/sim/tests/tool_catalogue.rs +++ b/sim/tests/tool_catalogue.rs @@ -33,7 +33,7 @@ async fn humd_enriches_prompt_with_forager_catalogue() { // Forager advertises two fs tools + provides=["fs"]. Build the // hello inline (attach_mock_forager doesn't take `provides`). - let forager_cid = format!("sim-forager-{}", uuid::Uuid::new_v4()); + let forager_cid = ids::HumId::mint().to_string(); let _frx = humd.thrum.register_synthetic(forager_cid.clone()); let forager_hello = json!({ "chi":"hello","bee":["forager"],"hive":"humfs","version":"0.0.0", @@ -50,7 +50,7 @@ async fn humd_enriches_prompt_with_forager_catalogue() { humd.thrum.inject_tone(&forager_cid, forager_hello).await; // Worker captures the chi:"prompt" tone humd sends. - let worker_cid = format!("sim-worker-{}", uuid::Uuid::new_v4()); + let worker_cid = ids::HumId::mint().to_string(); let mut worker_rx = humd.thrum.register_synthetic(worker_cid.clone()); let hello = json!({ "chi":"hello","bee":["worker"],"hive":"claude-cli","version":"0.0.0", diff --git a/thrum-core/Cargo.toml b/thrum-core/Cargo.toml index c23d2b75..b14784c8 100644 --- a/thrum-core/Cargo.toml +++ b/thrum-core/Cargo.toml @@ -16,6 +16,7 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } +ids = { path = "../ids" } thiserror = { workspace = true } parking_lot = "0.12" strum = { version = "0.26", features = ["derive"] } diff --git a/thrum-core/src/prims.rs b/thrum-core/src/prims.rs index a164cd99..6e86c723 100644 --- a/thrum-core/src/prims.rs +++ b/thrum-core/src/prims.rs @@ -1,6 +1,5 @@ //! Primitives — sigil, rid, dusk, echo. -use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{json, Map, Value}; @@ -32,30 +31,9 @@ pub fn now_ms() -> i64 { .unwrap_or(0) } -static RID_COUNTER: AtomicU64 = AtomicU64::new(0); - -/// Correlation id — monotonic counter joined with a base36 ms timestamp. -/// -/// Format matches the TS legacy: `"{tsBase36}-{counterBase36}"`. -pub fn rid() -> String { - let ts = now_ms().max(0) as u64; - let n = RID_COUNTER.fetch_add(1, Ordering::Relaxed); - format!("{}-{}", to_base36(ts), to_base36(n)) -} - -fn to_base36(mut n: u64) -> String { - if n == 0 { - return "0".into(); - } - const A: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; - let mut out = Vec::with_capacity(13); - while n > 0 { - out.push(A[(n % 36) as usize]); - n /= 36; - } - out.reverse(); - String::from_utf8(out).expect("base36 alphabet is ascii") -} +/// Correlation id — canonical HumId. Ts-prefixed inside, so freshly +/// minted rids are lex-sortable by mint time. +pub fn rid() -> String { ids::HumId::mint().to_string() } /// Absolute ms timestamp at which a tone with `dusk = dusk_in(ms)` expires. pub fn dusk_in(ms: i64) -> i64 { @@ -95,11 +73,12 @@ mod tests { } #[test] - fn rid_is_unique_and_monotonic_within_ms() { + fn rid_is_unique_and_canonical() { let a = rid(); let b = rid(); assert_ne!(a, b); - assert!(a.contains('-') && b.contains('-')); + assert!(ids::HumId::parse(&a).is_ok()); + assert!(ids::HumId::parse(&b).is_ok()); } #[test] diff --git a/thrumd/Cargo.toml b/thrumd/Cargo.toml index 2aa40391..27e2789d 100644 --- a/thrumd/Cargo.toml +++ b/thrumd/Cargo.toml @@ -8,6 +8,7 @@ description = "NDJSON unix-socket thrum server — listens, dispatches, broadcas [dependencies] hum-paths = { path = "../hum-paths" } thrum-core = { path = "../thrum-core" } +ids = { path = "../ids" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -15,7 +16,6 @@ tracing = { workspace = true } async-trait = { workspace = true } anyhow = { workspace = true } parking_lot = { workspace = true } -uuid = { version = "1", features = ["v4"] } governor = "0.6" [dev-dependencies] diff --git a/thrumd/src/conn.rs b/thrumd/src/conn.rs index ff14d45b..b166c73e 100644 --- a/thrumd/src/conn.rs +++ b/thrumd/src/conn.rs @@ -10,15 +10,13 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tokio::sync::mpsc; use tracing::{info, trace, warn}; -use uuid::Uuid; - use crate::registry::Reach; use crate::{ breath_tone, chi_of, echo_tone, rid_of, short, tone_is_dusk, validate_envelope, Thrum, }; pub async fn run(thrum: Thrum, sock: UnixStream) { - let client_id = Uuid::new_v4().to_string(); + let client_id = ids::HumId::mint().to_string(); let (reach, rx) = Reach::new(client_id.clone()); let reach = Arc::new(reach); From 0fd98a46e32c53c1d05d678bb736cb6b4ff83671 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sun, 31 May 2026 09:39:19 +0000 Subject: [PATCH 14/18] thehum: per-humd authored chi log + delete vestigial hums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every humd maintains a signed append-only NDJSON ring of every chi event it observes. The log is the only authoritative store of activity; bees.json, sid -> bee routes, session state are projections via thehum.replay(). New crate: thehum - Event: chi + sid + rid + body + author hid + seq + ts_ms + prev_hash + sig - HumId::mint() rids; humd.key signs; sha256 hash chain - TheHum::open / append / tail / range / replay / snapshot / enforce_retention / anchor - Retention modes: archive, rolling, light (configurable per humd) - Snapshots: Merkle root over BTreeMap of state leaves, emitted as chi:snapshot into own log - AnchorBackend trait + EvmAnchor scaffold for on-chain commitments (production users wire their own signer) - Sorted-key canonical JSON for hashing+signing; sig stripped from canonical bytes so chain integrity is sig-agnostic - 24 unit tests + 7 end-to-end tests pass Integration - humd opens TheHum from humd_key + hum_paths::thehum_dir() at boot - Replays the log to rebuild bee manifests (pure handler; reads event.ts_ms not now()) - Appends every incoming tone before routing (chi log -> handler chain) - Spawns 30s snapshot+retention background task - New chi:Backfill handler responds with thehum.range(author, from) - Backfill enum variant added to thrum-core/chi.rs Determinism scrub - humd: nestler_id fallback uses client_id (was SystemTime ms) - humd: cwd fallback is "/" (was env::var HOME) — replay must not read env - humd: sorted iteration in worker selection + forager tool catalogue during chi:Prompt handler (was HashMap order) Deletions - hums crate (vestigial; was Hums::load() with discarded result) - hum_paths::hums_json (no callers) New surfaces - hum thehum {status|tail|range|verify|replay} - humctl thehum (health check, no daemon needed) - thehum::layout module — single source of truth for seq.bin / snapshots/ / root.txt names (no literal filenames outside thehum crate) Tests: 275 passed across the workspace (was 247). --- Cargo.lock | 60 ++- Cargo.toml | 2 +- drone/src/lib.rs | 1 + hum-paths/src/lib.rs | 6 +- hum/Cargo.toml | 3 + hum/src/main.rs | 218 +++++++++++ humctl/Cargo.toml | 1 + humctl/src/main.rs | 73 ++++ humd/Cargo.toml | 2 +- humd/src/lib.rs | 168 ++++++++- hums/Cargo.toml | 13 - hums/src/lib.rs | 603 ------------------------------ sim/src/lib.rs | 2 + thehum/Cargo.toml | 29 ++ thehum/README.md | 189 ++++++++++ thehum/src/anchor.rs | 194 ++++++++++ thehum/src/append.rs | 193 ++++++++++ thehum/src/canon.rs | 76 ++++ thehum/src/layout.rs | 8 + thehum/src/lib.rs | 177 +++++++++ thehum/src/read.rs | 150 ++++++++ thehum/src/retention.rs | 122 ++++++ thehum/src/sign.rs | 68 ++++ thehum/src/snapshot.rs | 142 +++++++ thehum/tests/end_to_end.rs | 285 ++++++++++++++ thrum-clients/go/thrum/chi.go | 3 + thrum-clients/python/thrum/chi.py | 3 + thrum-clients/ts/chi.ts | 2 + thrum-core/src/chi.rs | 4 + 29 files changed, 2150 insertions(+), 647 deletions(-) delete mode 100644 hums/Cargo.toml delete mode 100644 hums/src/lib.rs create mode 100644 thehum/Cargo.toml create mode 100644 thehum/README.md create mode 100644 thehum/src/anchor.rs create mode 100644 thehum/src/append.rs create mode 100644 thehum/src/canon.rs create mode 100644 thehum/src/layout.rs create mode 100644 thehum/src/lib.rs create mode 100644 thehum/src/read.rs create mode 100644 thehum/src/retention.rs create mode 100644 thehum/src/sign.rs create mode 100644 thehum/src/snapshot.rs create mode 100644 thehum/tests/end_to_end.rs diff --git a/Cargo.lock b/Cargo.lock index 523a9990..cfc31151 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -433,6 +433,7 @@ dependencies = [ "bp7 0.10.7", "ensemble", "hum-paths", + "ids", "nest-common", "serde", "serde_json", @@ -571,6 +572,7 @@ dependencies = [ "async-trait", "command-group", "hum-paths", + "ids", "nest", "nest-common", "serde_json", @@ -1243,6 +1245,7 @@ dependencies = [ "async-trait", "ed25519-dalek 2.2.0", "hex", + "ids", "iroh", "lru 0.12.5", "parking_lot", @@ -1631,6 +1634,7 @@ dependencies = [ "async-stream", "ensemble", "hum-paths", + "ids", "nest-common", "prost", "serde_json", @@ -1653,6 +1657,7 @@ dependencies = [ "futures-util", "hex", "hum-paths", + "ids", "nest-common", "serde", "serde_json", @@ -1896,11 +1901,14 @@ name = "hum" version = "0.31.18" dependencies = [ "anyhow", + "chrono", "clap", "config", + "ed25519-dalek 2.2.0", "hum-paths", "humd", "serde_json", + "thehum", "thrum-core", "tokio", "tracing", @@ -1928,6 +1936,7 @@ dependencies = [ "anyhow", "hum-paths", "service-manager", + "thehum", ] [[package]] @@ -1943,7 +1952,6 @@ dependencies = [ "ensemble", "hex", "hum-paths", - "hums", "ids", "mcp", "metrics", @@ -1955,6 +1963,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "thehum", "thrum-core", "thrumd", "tokio", @@ -1990,17 +1999,6 @@ dependencies = [ "tree-sitter-typescript", ] -[[package]] -name = "hums" -version = "0.31.18" -dependencies = [ - "hum-paths", - "parking_lot", - "serde", - "serde_json", - "tracing", -] - [[package]] name = "hybrid-array" version = "0.4.12" @@ -2234,6 +2232,10 @@ name = "ids" version = "0.31.18" dependencies = [ "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "uuid", ] [[package]] @@ -2958,6 +2960,7 @@ dependencies = [ "anyhow", "async-trait", "command-group", + "ids", "libc", "lru 0.12.5", "metrics", @@ -2984,6 +2987,7 @@ dependencies = [ "ensemble", "futures", "hum-paths", + "ids", "lru 0.12.5", "mcp", "metrics", @@ -3484,6 +3488,7 @@ dependencies = [ "bytes", "chrono", "hum-paths", + "ids", "serde", "serde_json", "thrum-core", @@ -3492,7 +3497,6 @@ dependencies = [ "tower 0.5.3", "tracing", "tracing-subscriber", - "uuid", ] [[package]] @@ -3537,6 +3541,7 @@ dependencies = [ "ensemble", "hex", "hum-paths", + "ids", "nest-common", "parking_lot", "reqwest 0.12.28", @@ -4781,6 +4786,7 @@ dependencies = [ "config", "ensemble", "humd", + "ids", "mcp", "nest", "parking_lot", @@ -4790,7 +4796,6 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "uuid", ] [[package]] @@ -5121,6 +5126,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thehum" +version = "0.31.18" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "ed25519-dalek 2.2.0", + "ensemble", + "hex", + "hum-paths", + "ids", + "parking_lot", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 1.0.69", + "thrum-core", + "tokio", + "tracing", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5176,6 +5205,7 @@ version = "0.31.18" dependencies = [ "codegen", "hex", + "ids", "parking_lot", "serde", "serde_json", @@ -5192,13 +5222,13 @@ dependencies = [ "async-trait", "governor", "hum-paths", + "ids", "parking_lot", "serde", "serde_json", "thrum-core", "tokio", "tracing", - "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cf261c07..a58bcbfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ members = [ "hives/claude-cli", "hives/claude-repl", "hives/humfs", - "hums", "drone", "penny", "ensemble", @@ -27,6 +26,7 @@ members = [ "hives/gsm-modem", "hives/ollama-server", "hives/bp7", + "thehum", ] [workspace.package] diff --git a/drone/src/lib.rs b/drone/src/lib.rs index c856c306..21bd7934 100644 --- a/drone/src/lib.rs +++ b/drone/src/lib.rs @@ -622,6 +622,7 @@ fn chi_label(chi: Chi) -> &'static str { Chi::KadFindNode => "kad-find-node", Chi::KadFindNodeResp => "kad-find-node-resp", Chi::ToolInfo => "tool-info", + Chi::Backfill => "backfill", } } diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index d2fa538f..980d1793 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -145,6 +145,9 @@ pub fn peers_json() -> PathBuf { config_dir().join("peers.json") } /// Drift rings directory (`drift/YYYY-MM-DD.ndjson`). pub fn drift_dir() -> PathBuf { state_dir().join("drift") } +/// thehum chi-log directory (`thehum/YYYY-MM-DD.ndjson` + seq.bin + snapshots/). +pub fn thehum_dir() -> PathBuf { state_dir().join("thehum") } + /// Cloned hum source tree (recipes + hive installers). pub fn src_dir() -> PathBuf { data_dir().join("src") } @@ -153,9 +156,6 @@ pub fn humd_bin() -> PathBuf { home().join(".local/bin/humd") } -/// hums.json (session registry). -pub fn hums_json() -> PathBuf { state_dir().join("hums.json") } - /// Per-bee config file for a given hive kind (e.g. `ollama-server.json`). pub fn bee_config(kind: &str) -> PathBuf { config_dir().join("bees").join(format!("{kind}.json")) diff --git a/hum/Cargo.toml b/hum/Cargo.toml index a6b4f885..a3fc4937 100644 --- a/hum/Cargo.toml +++ b/hum/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" hum-paths = { path = "../hum-paths" } humd = { path = "../humd" } config = { path = "../config" } +thehum = { path = "../thehum" } anyhow = "1" clap = { version = "4", features = ["derive"] } serde_json = "1" @@ -18,3 +19,5 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } thrum-core = { path = "../thrum-core" } +ed25519-dalek = "2" +chrono = { workspace = true } diff --git a/hum/src/main.rs b/hum/src/main.rs index dd203819..1e115d87 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -12,6 +12,8 @@ //! hum nest list orchd-managed bees (delegates to `orchd status`) //! hum penny show lifetime counters //! hum recipes [name] list recipes / point at one +//! hum thehum inspect the persistent chi log +//! (status | tail | range | verify | replay) //! hum update self-update from latest GitHub release //! hum uninstall remove service + binary (state preserved) //! hum version print version @@ -82,6 +84,17 @@ enum Cmd { /// Recipe name (e.g. "opencode"). Omit to list. name: Option, }, + /// Inspect the persistent chi log (thehum). + /// hum thehum status dir, file count, seq, snapshot + /// hum thehum tail [-n N] most recent daily file (default 20) + /// hum thehum range --author filter by author + seq range + /// --from [--to ] + /// hum thehum verify check hash chain + signatures + /// hum thehum replay count events by chi kind + Thehum { + #[command(subcommand)] + verb: ThehumVerb, + }, /// Stop the service and remove the humd binary. State preserved. Uninstall, /// Check for a newer release and self-update. Compares the local @@ -94,6 +107,34 @@ enum Cmd { }, } +#[derive(Subcommand)] +enum ThehumVerb { + /// Print dir, file count, total seq, latest snapshot height + ts. + Status, + /// Tail the most recent daily file as compact JSON, one event per line. + Tail { + /// Number of trailing events to show. + #[arg(short = 'n', long, default_value_t = 20)] + n: usize, + }, + /// Filter events by author hid and seq range. + Range { + /// Author humd hid (hex). + #[arg(long)] + author: String, + /// Inclusive lower bound on seq. + #[arg(long)] + from: u64, + /// Inclusive upper bound on seq. + #[arg(long)] + to: Option, + }, + /// Verify hash chain + signatures across the whole log. + Verify, + /// Replay the log, counting events by chi kind. + Replay, +} + fn main() -> Result<()> { hum_paths::init(); let cli = Cli::parse(); @@ -107,6 +148,7 @@ fn main() -> Result<()> { Some(Cmd::Nest) => nest(), Some(Cmd::Penny) => penny(), Some(Cmd::Recipes { name }) => recipes(name), + Some(Cmd::Thehum { verb }) => thehum_cmd(verb), Some(Cmd::Uninstall) => uninstall(), Some(Cmd::Update { force }) => update(force), } @@ -971,3 +1013,179 @@ fn uninstall() -> Result<()> { println!("state preserved. `./install purge` to wipe."); Ok(()) } + +// ── thehum: persistent chi-log inspector ───────────────────────────────── + +fn thehum_cmd(verb: ThehumVerb) -> Result<()> { + match verb { + ThehumVerb::Status => thehum_status(), + ThehumVerb::Tail { n } => thehum_tail(n), + ThehumVerb::Range { author, from, to } => thehum_range(&author, from, to), + ThehumVerb::Verify => thehum_verify(), + ThehumVerb::Replay => thehum_replay(), + } +} + +/// Ndjson files in the thehum dir, sorted lexicographically (YYYY-MM-DD). +fn thehum_ndjson_files(dir: &Path) -> Result> { + let mut files: Vec = std::fs::read_dir(dir) + .with_context(|| format!("readdir {}", dir.display()))? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ndjson")) + .collect(); + files.sort(); + Ok(files) +} + +/// Deserialize every well-formed ndjson line into a thehum::Event. +fn thehum_load_all(dir: &Path) -> Result> { + let mut out = Vec::new(); + for path in thehum_ndjson_files(dir)? { + let content = std::fs::read_to_string(&path) + .with_context(|| format!("read {}", path.display()))?; + for (i, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { continue; } + let ev: thehum::Event = serde_json::from_str(line) + .with_context(|| format!("{}:{}: malformed event", path.display(), i + 1))?; + out.push(ev); + } + } + Ok(out) +} + +fn thehum_status() -> Result<()> { + let dir = hum_paths::thehum_dir(); + if !dir.exists() { + println!("thehum dir: {} (does not exist yet)", dir.display()); + return Ok(()); + } + let files = thehum_ndjson_files(&dir)?; + let seq = std::fs::read(thehum::layout::seq_file(&dir)).ok().and_then(|b| { + if b.len() == 8 { + let mut a = [0u8; 8]; + a.copy_from_slice(&b); + Some(u64::from_le_bytes(a)) + } else { None } + }).unwrap_or(0); + + // Last chi=="snapshot" wins. + let mut latest_height: Option = None; + let mut latest_ts_ms: Option = None; + let events = thehum_load_all(&dir).unwrap_or_default(); + for e in &events { + if e.chi == "snapshot" { + latest_height = e.body.get("height").and_then(|v| v.as_u64()).or(latest_height); + latest_ts_ms = Some(e.ts_ms); + } + } + + println!("thehum dir: {}", dir.display()); + println!("daily files: {}", files.len()); + println!("total seq: {seq}"); + match (latest_height, latest_ts_ms) { + (Some(h), Some(ts)) => println!("latest snapshot: height={h} ts_ms={ts} ({})", fmt_ts_ms(ts)), + _ => println!("latest snapshot: (none)"), + } + Ok(()) +} + +fn thehum_tail(n: usize) -> Result<()> { + let dir = hum_paths::thehum_dir(); + let files = thehum_ndjson_files(&dir)?; + let Some(last) = files.last() else { + println!("(no ndjson files in {})", dir.display()); + return Ok(()); + }; + let content = std::fs::read_to_string(last) + .with_context(|| format!("read {}", last.display()))?; + let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect(); + let start = lines.len().saturating_sub(n); + for line in &lines[start..] { + // Re-emit compactly via Event round-trip. + match serde_json::from_str::(line) { + Ok(ev) => println!("{}", serde_json::to_string(&ev).unwrap_or_else(|_| (*line).to_string())), + Err(_) => println!("{line}"), + } + } + Ok(()) +} + +fn thehum_range(author: &str, from: u64, to: Option) -> Result<()> { + let dir = hum_paths::thehum_dir(); + let events = thehum_load_all(&dir)?; + let mut matched: Vec<&thehum::Event> = events.iter() + .filter(|e| e.author == author && e.seq >= from && to.map(|hi| e.seq <= hi).unwrap_or(true)) + .collect(); + matched.sort_by_key(|e| e.seq); + if matched.is_empty() { + println!("(no events matching author={author} from={from}{})", + to.map(|t| format!(" to={t}")).unwrap_or_default()); + return Ok(()); + } + println!(" {:>8} {:<10} {:<13} {}", "SEQ", "CHI", "TS_MS", "RID"); + for e in &matched { + println!(" {:>8} {:<10} {:<13} {}", e.seq, e.chi, e.ts_ms, e.rid); + } + println!("\n{} event(s).", matched.len()); + Ok(()) +} + +fn thehum_verify() -> Result<()> { + let dir = hum_paths::thehum_dir(); + let mut events = thehum_load_all(&dir)?; + events.sort_by_key(|e| (e.author.clone(), e.seq)); + if events.is_empty() { + println!("OK (empty log)"); + return Ok(()); + } + + let key_path = hum_paths::humd_key(); + let bytes = std::fs::read(&key_path) + .with_context(|| format!("read {}", key_path.display()))?; + if bytes.len() != 32 { + anyhow::bail!("humd.key is {} bytes, expected 32", bytes.len()); + } + let mut seed = [0u8; 32]; + seed.copy_from_slice(&bytes); + let signing = ed25519_dalek::SigningKey::from_bytes(&seed); + let pubkey = signing.verifying_key(); + + match thehum::read::verify_chain(&events, &pubkey) { + Ok(()) => { + println!("OK ({} events verified)", events.len()); + Ok(()) + } + Err(e) => { + println!("VIOLATION: {e}"); + Err(e) + } + } +} + +fn thehum_replay() -> Result<()> { + use std::collections::BTreeMap; + let dir = hum_paths::thehum_dir(); + let mut events = thehum_load_all(&dir)?; + events.sort_by_key(|e| (e.author.clone(), e.seq)); + + let mut counts: BTreeMap = BTreeMap::new(); + for e in &events { + *counts.entry(e.chi.clone()).or_default() += 1; + } + + println!(" {:<16} {}", "CHI", "COUNT"); + for (chi, n) in &counts { + println!(" {:<16} {}", chi, n); + } + println!("\n{} event(s) across {} kind(s).", events.len(), counts.len()); + Ok(()) +} + +fn fmt_ts_ms(ts_ms: i64) -> String { + use chrono::DateTime; + DateTime::from_timestamp_millis(ts_ms) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| ts_ms.to_string()) +} diff --git a/humctl/Cargo.toml b/humctl/Cargo.toml index 31e47203..b2162c98 100644 --- a/humctl/Cargo.toml +++ b/humctl/Cargo.toml @@ -13,3 +13,4 @@ path = "src/main.rs" service-manager = "0.7" anyhow = { workspace = true } hum-paths = { path = "../hum-paths" } +thehum = { path = "../thehum" } diff --git a/humctl/src/main.rs b/humctl/src/main.rs index 9f19e389..d826018d 100644 --- a/humctl/src/main.rs +++ b/humctl/src/main.rs @@ -19,6 +19,7 @@ Usage: humctl status humctl logs [-n LINES] humctl health + humctl thehum "; fn main() -> ExitCode { @@ -39,6 +40,7 @@ fn run() -> Result<()> { "status" => status(), "logs" => logs(parse_lines(args.collect::>())), "health" => health(), + "thehum" => thehum(), other => bail!("unknown verb '{other}'\n{USAGE}"), } } @@ -115,5 +117,76 @@ fn health() -> Result<()> { } } +fn thehum() -> Result<()> { + let dir = hum_paths::thehum_dir(); + println!("thehum dir: {}", dir.display()); + if !dir.exists() { + println!("files: 0"); + println!("seq: 0"); + println!("latest day: (none)"); + println!("snapshots: 0"); + println!("most recent root: (none)"); + println!("total bytes: 0"); + return Ok(()); + } + + let mut ndjson: Vec = Vec::new(); + let mut total_bytes: u64 = 0; + for ent in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? { + let ent = ent?; + let ft = ent.file_type()?; + if ft.is_file() { + total_bytes += ent.metadata().map(|m| m.len()).unwrap_or(0); + let path = ent.path(); + if path.extension().and_then(|x| x.to_str()) == Some("ndjson") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + ndjson.push(stem.to_string()); + } + } + } + } + ndjson.sort(); + let latest = ndjson.last().cloned().unwrap_or_else(|| "(none)".to_string()); + + let seq: u64 = std::fs::read(thehum::layout::seq_file(&dir)) + .ok() + .and_then(|b| if b.len() == 8 { + let mut a = [0u8; 8]; a.copy_from_slice(&b); Some(u64::from_le_bytes(a)) + } else { None }) + .unwrap_or(0); + + let snap_dir = thehum::layout::snapshots_dir(&dir); + let snap_count = match std::fs::read_dir(&snap_dir) { + Ok(it) => { + let mut n: usize = 0; + for e in it { if e.is_ok() { n += 1; } } + n + } + Err(_) => 0, + }; + // snapshots/ bytes count toward total too. + if let Ok(it) = std::fs::read_dir(&snap_dir) { + for e in it.flatten() { + if e.file_type().map(|f| f.is_file()).unwrap_or(false) { + total_bytes += e.metadata().map(|m| m.len()).unwrap_or(0); + } + } + } + + let root = std::fs::read_to_string(thehum::layout::root_file(&dir)) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "(none)".to_string()); + + println!("files: {}", ndjson.len()); + println!("seq: {seq}"); + println!("latest day: {latest}"); + println!("snapshots: {snap_count}"); + println!("most recent root: {root}"); + println!("total bytes: {total_bytes}"); + Ok(()) +} + #[cfg(target_os = "macos")] extern "C" { fn geteuid() -> u32; } diff --git a/humd/Cargo.toml b/humd/Cargo.toml index 5deefdc5..99a649a9 100644 --- a/humd/Cargo.toml +++ b/humd/Cargo.toml @@ -27,8 +27,8 @@ config = { path = "../config" } ids = { path = "../ids" } drift = { path = "../drift" } nest = { path = "../nest" } -hums = { path = "../hums" } penny = { path = "../penny" } +thehum = { path = "../thehum" } drone = { path = "../drone" } ensemble = { path = "../ensemble" } serde = { workspace = true } diff --git a/humd/src/lib.rs b/humd/src/lib.rs index 58f25a6a..1d91eff8 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -76,6 +76,10 @@ pub struct DaemonConfig { /// Peers to dial on boot. `from_env` reads /// `$XDG_CONFIG_HOME/hum/peers.json`; missing file = empty list. pub bootstrap_peers: Vec, + /// thehum persistence config (retention, snapshot cadence, encryption). + /// `from_env` defaults to `thehum::Config::default()` unless hum.json + /// carries a `thehum` section. + pub thehum_cfg: Option, } impl DaemonConfig { @@ -114,6 +118,7 @@ impl DaemonConfig { waneman: None, humd_key, bootstrap_peers, + thehum_cfg: None, } } } @@ -153,7 +158,6 @@ where warn!(addr = %cfg.hum_cfg.humd.metrics_addr, "humd.metrics.addr_parse_failed"); } - let _hums = hums::Hums::load(); let penny = penny::Penny::load(&cfg.penny_path); penny.clone().spawn_persister(cfg.penny_path.clone(), cfg.penny_persist_interval); @@ -161,6 +165,23 @@ where let _drift = drift::Drift::with_store_dir(hum_paths::drift_dir()); let _drone = drone::Drone::new(); + let thehum_handle: Option> = cfg.humd_key.as_ref().and_then(|k| { + match thehum::TheHum::open( + &hum_paths::thehum_dir(), + k.0.clone(), + cfg.thehum_cfg.clone().unwrap_or_default(), + ) { + Ok(t) => Some(Arc::new(t)), + Err(e) => { + warn!(err = %e, "thehum.open.failed"); + None + } + } + }); + if let Some(t) = thehum_handle.as_ref() { + info!(author = %t.author_hid(), dir = %t.dir().display(), "thehum.opened"); + } + // Bring up an Ensemble from the persisted identity when the caller // didn't supply one. Sim provides its own pre-wired Ensemble (with // InMemoryEndpoints); the production binary lets us mint one here so @@ -230,6 +251,48 @@ where let observers: Observers = Arc::new(RwLock::new(HashMap::new())); let hive_tag = cfg.hum_cfg.nest.default.clone(); let manifests: Manifests = Arc::new(parking_lot::RwLock::new(HashMap::new())); + if let Some(thehum) = thehum_handle.as_ref() { + let manifests_for_replay = manifests.clone(); + if let Err(e) = thehum.replay(|event| { + let body = &event.body; + let client_id = body.get("client_id").and_then(Value::as_str) + .or_else(|| body.get("nestlerId").and_then(Value::as_str)) + .or_else(|| body.get("from").and_then(Value::as_str)) + .map(str::to_string); + let Some(client_id) = client_id else { return }; + match event.chi.as_str() { + "hello" => { + let name = body.get("hive").and_then(Value::as_str) + .or_else(|| body.get("from").and_then(Value::as_str)) + .unwrap_or(&client_id) + .to_string(); + let version = body.get("version").and_then(Value::as_str) + .unwrap_or("0.0.0").to_string(); + let proto = body.get("protoVersion").and_then(Value::as_str) + .unwrap_or(thrum_core::THRUM_VERSION).to_string(); + let mut manifest = ensemble::HiveManifest::new(name, version, proto); + manifest.bee = body.get("bee").and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + manifest.models = body.get("models").and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + manifest.hid = body.get("hid").and_then(Value::as_str) + .and_then(|s| ensemble::Hid::from_hex(s).ok()); + manifest.nestler_id = body.get("nestlerId").and_then(Value::as_str).map(str::to_string); + manifests_for_replay.write().insert(client_id, manifest); + } + "disconnect" | "forget" => { + manifests_for_replay.write().remove(&client_id); + } + _ => {} + } + let _ = event.ts_ms; + }) { + warn!(err = %e, "thehum.replay.failed"); + } + info!(bees = manifests.read().len(), "thehum.replay.bees-derived"); + } let sid_origins: Arc>> = Arc::new(parking_lot::RwLock::new(HashMap::new())); let tool_routes: Arc>> = @@ -256,6 +319,7 @@ where alias_resolver: alias_resolver.clone(), tool_routes_peer: tool_routes_peer.clone(), incoming_tool_calls: incoming_tool_calls.clone(), + thehum: thehum_handle.clone(), }); thrum.set_sink(sink); if bind_thrum { @@ -312,6 +376,40 @@ where }); } + if let Some(thehum) = thehum_handle.clone() { + let manifests_for_snapshot = manifests.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(30)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last_retention_ms: i64 = 0; + loop { + tick.tick().await; + let now_ms: i64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + if thehum.should_snapshot(now_ms) { + let leaves: std::collections::BTreeMap = manifests_for_snapshot + .read() + .iter() + .map(|(cid, m)| (cid.clone(), serde_json::to_value(m).unwrap_or_default())) + .collect(); + match thehum.snapshot(leaves).await { + Ok(root) => trace!(root = %hex::encode(root), "thehum.snapshot.ok"), + Err(e) => warn!(err = %e, "thehum.snapshot.failed"), + } + } + if now_ms.saturating_sub(last_retention_ms) >= 3_600_000 { + match thehum.enforce_retention() { + Ok(r) => trace!(removed = r.removed_files, kept = r.kept_files, "thehum.retention.ok"), + Err(e) => warn!(err = %e, "thehum.retention.failed"), + } + last_retention_ms = now_ms; + } + } + }); + } + // Auto-update — in-process daily check. Skipped under sim/test // (caller-owned thrum means we're embedded in someone else's // runtime, not a real boot). The job shells to curl + the @@ -464,6 +562,9 @@ struct HumdSink { /// the forager's outbound chi:"tool-result" can be stamped /// `to:` and routed back through the ensemble. incoming_tool_calls: Arc>>, + /// Per-humd authored chi log. Every tone seen by `hear()` lands + /// here before dispatch. None in tests/sims without a humd_key. + thehum: Option>, } /// AliasResolver backed by the bootstrap peers.json `alias` field. @@ -562,6 +663,24 @@ impl ToneSink for HumdSink { .cloned() .and_then(|v| serde_json::from_value(v).ok()); + // Authored chi log: every locally-originated tone is appended + // before dispatch. Ensemble-injected tones are peers' authored + // events and are never re-attributed here. + if let Some(thehum) = self.thehum.as_ref() { + if client_id != "ensemble" { + let sid = tone.get("sid").and_then(Value::as_str).and_then(|s| { + ids::HumId::parse(s).ok().or_else(|| Some(ids::HumId::from_foreign(s))) + }); + let rid = tone.get("rid").and_then(Value::as_str) + .map(|s| ids::HumId::parse(s).unwrap_or_else(|_| ids::HumId::from_foreign(s))) + .unwrap_or_else(ids::HumId::mint); + let body = serde_json::to_value(&tone).unwrap_or_default(); + if let Err(e) = thehum.append(chi_str, sid, rid, body).await { + warn!(client_id, %chi_str, err = %e, "thehum.append.failed"); + } + } + } + // Worker passthrough: any output tone (chunk / finish / error / // tool-call / tool-info / session-ready) coming from a client // whose manifest declares `bee` containing "worker" gets @@ -891,13 +1010,7 @@ impl ToneSink for HumdSink { .and_then(|v| serde_json::from_value(v.clone()).ok()); let nestler_id = tone.get("nestlerId").and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| { - let ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - format!("{}-{}", client_id, ms) - }); + .unwrap_or_else(|| client_id.to_string()); let mut manifest = ensemble::HiveManifest::new(name, version, proto); manifest.propensity = propensity; manifest.chis = chis; @@ -1050,7 +1163,7 @@ impl ToneSink for HumdSink { let model = tone.get("modelId").and_then(Value::as_str).unwrap_or("sonnet").to_string(); let cwd_raw = tone.get("cwd").and_then(Value::as_str) .map(str::to_string) - .unwrap_or_else(|| std::env::var("HOME").unwrap_or_else(|_| "/".into())); + .unwrap_or_else(|| "/".into()); // cwd may carry a `hum:///` URI pinning a // remote fs hive. Parse, resolve alias to Hid via the // peers.json resolver, stash (sid → fs_hid) so the @@ -1125,7 +1238,9 @@ impl ToneSink for HumdSink { let pick = { let m = self.manifests.read(); let mut found: Option = None; - for (cid, man) in m.iter() { + let mut sorted: Vec<_> = m.iter().collect(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + for (cid, man) in sorted { if man.bee.iter().any(|b| b == "worker") && man.models.iter().any(|m| m == &model) { @@ -1180,7 +1295,9 @@ impl ToneSink for HumdSink { let mut tools: Vec = Vec::new(); let mut caps: std::collections::BTreeSet = std::collections::BTreeSet::new(); - for man in m.values() { + let mut sorted: Vec<_> = m.iter().collect(); + sorted.sort_by(|a, b| a.0.cmp(b.0)); + for (_, man) in sorted { if !man.bee.iter().any(|b| b == "forager") { continue; } for t in &man.tools { tools.push(serde_json::json!({ @@ -1469,6 +1586,35 @@ impl ToneSink for HumdSink { trace!(call_id, "tool_result.unrouted"); } } + Some(Chi::Backfill) => { + // Requester wants this humd's authored events for `author` + // from `from` seq onward. One backfill-event tone per row. + let Some(thehum) = self.thehum.as_ref() else { + trace!(client_id, "backfill.no-thehum"); + return; + }; + let author = tone.get("author").and_then(Value::as_str).unwrap_or("").to_string(); + let from = tone.get("from").and_then(Value::as_u64).unwrap_or(0); + if author.is_empty() { + warn!(client_id, "backfill.no-author"); + return; + } + match thehum.range(&author, from) { + Ok(events) => { + trace!(client_id, %author, from, count = events.len(), "backfill.serve"); + for ev in events { + let body = serde_json::to_value(&ev).unwrap_or_default(); + let reply = serde_json::json!({ + "chi": "backfill-event", + "rid": ev.rid, + "event": body, + }); + self.thrum.thrum_to(client_id, reply); + } + } + Err(e) => warn!(client_id, %author, from, err = %e, "backfill.range.failed"), + } + } Some(Chi::Curate) | Some(Chi::ReleasePermit) | Some(Chi::TendrilResult) diff --git a/hums/Cargo.toml b/hums/Cargo.toml deleted file mode 100644 index 8077404e..00000000 --- a/hums/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "hums" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Hum state persistence — registry of sessions, atomic JSON load/save under XDG_STATE_HOME." - -[dependencies] -hum-paths = { path = "../hum-paths" } -serde = { workspace = true } -serde_json = { workspace = true } -parking_lot = "0.12" -tracing = { workspace = true } diff --git a/hums/src/lib.rs b/hums/src/lib.rs deleted file mode 100644 index 6747aa3f..00000000 --- a/hums/src/lib.rs +++ /dev/null @@ -1,603 +0,0 @@ -//! Hum state persistence. -//! -//! Each hum is a long-lived session record: which nest (Claude CLI, future -//! backends) drives it, which bees (OpenCode and any hear-only -//! observers) are attached, and the per-turn cached fields the daemon -//! needs to cold-respawn an inference process. -//! -//! Persisted as a single JSON object keyed by session id at -//! `${XDG_STATE_HOME or HOME/.local/state}/hum/hums.json`. Writes are -//! atomic (tmp + rename) so a crash mid-flush can't corrupt the file. -//! -//! On load we backfill legacy shapes so older state files survive an -//! upgrade — flat `claudeSessionId` collapses into `nest[0].id`, -//! `opencodeSessionId` / `plugin[]` collapse into `nestled[]`, and -//! `nest[]` is capped at 1. - -use std::collections::HashMap; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -/// One entry of `Hum.nest` — the driver process backing this session. -/// Capped at one in storage; the array shape is preserved for future -/// multi-nest provision (e.g. a side-by-side dry-run backend). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NestRef { - pub nest: String, - pub id: String, -} - -/// One entry of `Hum.nestled` — an attached observer or driver. The first -/// entry without `hear_only = true` is the active bee. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NestledRef { - pub bee: String, - pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub hear_only: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolSpec { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parameters: Option, -} - -/// A single session's persisted state. Field order mirrors the TS -/// `interface Hum` so the JSON shape stays diff-friendly. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Hum { - pub id: String, - /// Capped at 1 in storage. The array shape is kept for future - /// multi-nest provision. - #[serde(default)] - pub nest: Vec, - /// One driver, zero or more hear-only observers. - #[serde(default)] - pub nestled: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - pub model_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub needs_respawn: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_accessed: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_synced_petal: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub oc_server_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub thorns: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub external_tool_names: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub plan_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_system_prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_permissions: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_allowed_tools: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_context_tokens: Option, -} - -// ─── Registry ──────────────────────────────────────────────────────────────── - -/// The on-disk registry: `sid -> Hum`. Wrapped in an RwLock for shared -/// read/write across daemon subsystems. -pub struct Hums { - inner: RwLock>, - path: PathBuf, -} - -impl Hums { - pub fn state_dir() -> PathBuf { - hum_paths::state_dir() - } - - /// Default file path: `/hums.json`. - pub fn default_file() -> PathBuf { - hum_paths::hums_json() - } - - /// Load the registry from the default path, applying legacy backfill. - /// A missing or unparseable file yields an empty registry. - pub fn load() -> Self { - Self::load_from(Self::default_file()) - } - - /// Load from a specific path. Same backfill semantics as [`Hums::load`]. - pub fn load_from(path: PathBuf) -> Self { - let map = read_and_backfill(&path).unwrap_or_default(); - Hums { - inner: RwLock::new(map), - path, - } - } - - /// Path of the file this registry will be written to. - pub fn path(&self) -> &Path { - &self.path - } - - /// Number of hums currently in the registry. - pub fn len(&self) -> usize { - self.inner.read().len() - } - - pub fn is_empty(&self) -> bool { - self.inner.read().is_empty() - } - - /// Run a closure against the inner map under a read lock. Cloning a - /// snapshot is the caller's call — we hand out the borrow only for the - /// duration of the closure to keep lock scopes obvious. - pub fn with_read(&self, f: impl FnOnce(&HashMap) -> R) -> R { - f(&*self.inner.read()) - } - - /// Mutate the inner map under a write lock. The caller is responsible - /// for invoking [`Hums::save`] (or letting the daemon's tick do it). - pub fn with_write(&self, f: impl FnOnce(&mut HashMap) -> R) -> R { - f(&mut *self.inner.write()) - } - - /// Get a clone of a single hum by sid. - pub fn get(&self, sid: &str) -> Option { - self.inner.read().get(sid).cloned() - } - - /// Insert or replace a hum. - pub fn insert(&self, sid: String, hum: Hum) -> Option { - self.inner.write().insert(sid, hum) - } - - /// Remove a hum by sid. - pub fn remove(&self, sid: &str) -> Option { - self.inner.write().remove(sid) - } - - /// Atomically persist the registry to disk. The `sid` argument matches - /// the TS signature — currently the whole file is rewritten on every - /// save, but we accept the sid so callers can wire wane/drone hooks - /// without changing the call site later. - pub fn save(&self, _sid: Option<&str>) -> std::io::Result<()> { - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent)?; - } - let snapshot: HashMap = self.inner.read().clone(); - let bytes = serde_json::to_vec(&snapshot) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; - - // Same-directory tmp + rename keeps the swap on one filesystem, - // so it stays atomic on POSIX. - let tmp = tmp_sibling(&self.path); - { - let mut f = fs::File::create(&tmp)?; - f.write_all(&bytes)?; - f.sync_all()?; - } - fs::rename(&tmp, &self.path)?; - Ok(()) - } - - /// Drop hums that have been idle for more than `max_age_ms` and whose - /// `last_accessed` is set. Returns the number reaped. The TS daemon - /// also consults `nest.cell(sid)` to avoid reaping live processes — - /// that check has to happen at the call site since this crate doesn't - /// know about the nest. - pub fn reap_stale(&self, max_age_ms: i64) -> usize { - let now = now_ms(); - let mut guard = self.inner.write(); - let before = guard.len(); - guard.retain(|_sid, h| match h.last_accessed { - Some(ts) => (now - ts) < max_age_ms, - None => true, - }); - before - guard.len() - } - - /// Like [`Hums::reap_stale`] but skip any sid for which `is_alive(sid)` - /// returns true. Mirrors the TS `reapSessions` check against - /// `nest.cell(sid)`. - pub fn reap_stale_unless bool>(&self, max_age_ms: i64, is_alive: F) -> usize { - let now = now_ms(); - let mut guard = self.inner.write(); - let before = guard.len(); - guard.retain(|sid, h| { - let stale = match h.last_accessed { - Some(ts) => (now - ts) >= max_age_ms, - None => false, - }; - if !stale { - return true; - } - is_alive(sid) - }); - before - guard.len() - } -} - -// ─── Accessors (free functions, matching the TS API) ───────────────────────── - -/// First nest entry's id, or `None` if no nest is attached. -pub fn nest_id(h: &Hum) -> Option<&str> { - h.nest.first().map(|n| n.id.as_str()) -} - -/// First nest entry's name, or `None`. -pub fn nest_name(h: &Hum) -> Option<&str> { - h.nest.first().map(|n| n.nest.as_str()) -} - -/// Resolve the on-disk path for this hum's nest, given a resolver that -/// maps `(cwd, nest_id)` to a path. Mirrors TS `nestPath`, which calls -/// `getSessionPath(h.cwd, id)` — the resolver is owned by the nest crate -/// so we accept it as a closure. -pub fn nest_path(h: &Hum, resolver: F) -> Option -where - F: FnOnce(&str, &str) -> PathBuf, -{ - let id = nest_id(h)?; - let cwd = h.cwd.as_deref()?; - Some(resolver(cwd, id)) -} - -/// First nestled entry's id, or `None`. -pub fn nestled_id(h: &Hum) -> Option<&str> { - h.nestled.first().map(|n| n.id.as_str()) -} - -/// First nestled entry's name (`"opencode"`, etc.), or `None`. -pub fn bee_name(h: &Hum) -> Option<&str> { - h.nestled.first().map(|n| n.bee.as_str()) -} - -/// Update or create the single nest entry, replacing any existing one. -pub fn set_nest(h: &mut Hum, nest: impl Into, id: impl Into) { - let entry = NestRef { - nest: nest.into(), - id: id.into(), - }; - if h.nest.is_empty() { - h.nest.push(entry); - } else { - h.nest[0] = entry; - } -} - -// ─── Internals ─────────────────────────────────────────────────────────────── - -fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -fn tmp_sibling(p: &Path) -> PathBuf { - let mut name = p - .file_name() - .map(|s| s.to_os_string()) - .unwrap_or_else(|| std::ffi::OsString::from("hums.json")); - name.push(".tmp"); - p.with_file_name(name) -} - -/// Read `path` as JSON, apply legacy backfill on each entry, then -/// deserialize each value into a typed `Hum`. Returns `None` if the file -/// cannot be read or parsed — matches the TS catch-all. -fn read_and_backfill(path: &Path) -> Option> { - let raw = fs::read(path).ok()?; - let mut root: Value = serde_json::from_slice(&raw).ok()?; - let obj = root.as_object_mut()?; - - let mut id_back = 0usize; - let mut nest_back = 0usize; - let mut nestled_back = 0usize; - let mut out = HashMap::with_capacity(obj.len()); - - for (sid, entry) in obj.iter_mut() { - let Some(o) = entry.as_object_mut() else { - continue; - }; - - // Mint a fresh id when one is missing or non-string. The TS uses - // `mintId()`; we leave the field blank to avoid pulling the `ids` - // crate into the dep graph, and let the daemon mint a replacement - // on first touch. (Counts the backfill either way for parity with - // TS logging.) - let needs_id = !matches!(o.get("id"), Some(Value::String(s)) if !s.is_empty()); - if needs_id { - o.insert("id".into(), Value::String(String::new())); - id_back += 1; - } - - // Pull legacy flat fields; remove so they don't linger on writeback. - let claude_session_id = o - .remove("claudeSessionId") - .and_then(|v| v.as_str().map(str::to_string)); - let opencode_session_id = o - .remove("opencodeSessionId") - .and_then(|v| v.as_str().map(str::to_string)); - let _claude_session_path = o.remove("claudeSessionPath"); - let plugin_arr = o.remove("plugin"); - - // nest backfill — accept missing/non-array, cap to length 1. - let nest_ok = matches!(o.get("nest"), Some(Value::Array(_))); - if !nest_ok { - let nest_name = resolve_nest_name(o.get("cwd").and_then(|v| v.as_str())); - let id = claude_session_id.unwrap_or_default(); - o.insert( - "nest".into(), - Value::Array(vec![serde_json::json!({ "nest": nest_name, "id": id })]), - ); - nest_back += 1; - } else if let Some(Value::Array(arr)) = o.get_mut("nest") { - if arr.len() > 1 { - arr.truncate(1); - nest_back += 1; - } - } - - // nestled backfill — prefer plugin[] when present, else fall back - // to opencodeSessionId, else default to a single OC entry keyed - // by sid. - let nestled_ok = matches!(o.get("nestled"), Some(Value::Array(_))); - if !nestled_ok { - let new_arr = if let Some(Value::Array(plugins)) = plugin_arr { - plugins - .into_iter() - .map(|p| { - let bee = p - .get("plugin") - .and_then(|v| v.as_str()) - .unwrap_or("opencode") - .to_string(); - let id = p - .get("id") - .and_then(|v| v.as_str()) - .map(str::to_string) - .unwrap_or_else(|| sid.clone()); - serde_json::json!({ "bee": bee, "id": id }) - }) - .collect::>() - } else { - let id = opencode_session_id.unwrap_or_else(|| sid.clone()); - vec![serde_json::json!({ "bee": "opencode", "id": id })] - }; - o.insert("nestled".into(), Value::Array(new_arr)); - nestled_back += 1; - } - - match serde_json::from_value::(entry.clone()) { - Ok(hum) => { - out.insert(sid.clone(), hum); - } - Err(e) => { - tracing::warn!(sid = %sid, error = %e, "hums.backfill.skip"); - } - } - } - - if id_back + nest_back + nestled_back > 0 { - tracing::info!( - id = id_back, - nest = nest_back, - nestled = nestled_back, - "hum.backfilled" - ); - } - - Some(out) -} - -/// Default nest name when we have no other signal. The TS calls -/// `resolveNestName(cwd)` which inspects per-cwd config; from this crate -/// we don't have access to that, so we return the safe default and let -/// the daemon override on next write. -fn resolve_nest_name(_cwd: Option<&str>) -> &'static str { - "claude-cli" -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - fn tmpdir() -> PathBuf { - let base = std::env::temp_dir().join(format!( - "hums-test-{}-{}", - std::process::id(), - now_ms() - )); - fs::create_dir_all(&base).unwrap(); - base - } - - fn blank_hum(id: &str) -> Hum { - Hum { - id: id.into(), - nest: vec![], - nestled: vec![], - cwd: None, - model_id: "m".into(), - tools: None, - needs_respawn: None, - last_accessed: None, - last_synced_petal: None, - oc_server_url: None, - thorns: None, - external_tool_names: None, - plan_mode: None, - last_system_prompt: None, - last_permissions: None, - last_allowed_tools: None, - max_context_tokens: None, - } - } - - #[test] - fn load_missing_file_is_empty() { - let dir = tmpdir(); - let h = Hums::load_from(dir.join("absent.json")); - assert_eq!(h.len(), 0); - } - - #[test] - fn save_then_load_roundtrip() { - let dir = tmpdir(); - let path = dir.join("hums.json"); - let h = Hums::load_from(path.clone()); - let mut hum = blank_hum("abc"); - hum.nestled = vec![NestledRef { - bee: "opencode".into(), - id: "o1".into(), - hear_only: None, - }]; - hum.cwd = Some("/tmp".into()); - hum.model_id = "claude-opus-4-7".into(); - hum.last_accessed = Some(now_ms()); - hum.last_system_prompt = Some("sys".into()); - hum.last_allowed_tools = Some(vec!["Read".into()]); - hum.max_context_tokens = Some(123_456); - set_nest(&mut hum, "claude-cli", "n2"); - h.insert("sid-1".into(), hum); - h.save(None).unwrap(); - - let h2 = Hums::load_from(path); - let got = h2.get("sid-1").expect("present"); - assert_eq!(nest_id(&got), Some("n2")); - assert_eq!(nestled_id(&got), Some("o1")); - assert_eq!(got.model_id, "claude-opus-4-7"); - assert_eq!(got.max_context_tokens, Some(123_456)); - } - - #[test] - fn backfills_legacy_flat_fields() { - let dir = tmpdir(); - let path = dir.join("legacy.json"); - let legacy = r#"{ - "sid-old": { - "id": "X", - "claudeSessionId": "claude-1", - "opencodeSessionId": "oc-1", - "claudeSessionPath": "/p", - "cwd": "/work", - "modelId": "old-model" - }, - "sid-plugin": { - "id": "Y", - "modelId": "m", - "plugin": [ - { "plugin": "opencode", "id": "oc-2" }, - { "plugin": "ghost", "id": "g-1" } - ] - } - }"#; - fs::write(&path, legacy).unwrap(); - - let h = Hums::load_from(path); - let a = h.get("sid-old").unwrap(); - assert_eq!(nest_id(&a), Some("claude-1")); - assert_eq!(nestled_id(&a), Some("oc-1")); - assert_eq!(bee_name(&a), Some("opencode")); - - let b = h.get("sid-plugin").unwrap(); - assert_eq!(b.nestled.len(), 2); - assert_eq!(b.nestled[0].bee, "opencode"); - assert_eq!(b.nestled[1].bee, "ghost"); - assert_eq!(b.nestled[1].id, "g-1"); - } - - #[test] - fn nest_array_caps_at_one() { - let dir = tmpdir(); - let path = dir.join("multi.json"); - let raw = r#"{ - "sid": { - "id": "Z", - "modelId": "m", - "nest": [ - { "nest": "claude-cli", "id": "a" }, - { "nest": "future", "id": "b" } - ], - "nestled": [] - } - }"#; - fs::write(&path, raw).unwrap(); - let h = Hums::load_from(path); - let got = h.get("sid").unwrap(); - assert_eq!(got.nest.len(), 1); - assert_eq!(nest_id(&got), Some("a")); - } - - #[test] - fn reap_stale_drops_old_entries() { - let dir = tmpdir(); - let path = dir.join("reap.json"); - let h = Hums::load_from(path); - let now = now_ms(); - - let mut fresh = blank_hum("1"); - fresh.last_accessed = Some(now); - h.insert("fresh".into(), fresh); - - let mut stale = blank_hum("2"); - stale.last_accessed = Some(now - 10_000); - h.insert("stale".into(), stale); - - let reaped = h.reap_stale(5_000); - assert_eq!(reaped, 1); - assert!(h.get("fresh").is_some()); - assert!(h.get("stale").is_none()); - } - - #[test] - fn reap_stale_unless_keeps_alive() { - let dir = tmpdir(); - let path = dir.join("reap2.json"); - let h = Hums::load_from(path); - let now = now_ms(); - let mut stale = blank_hum("1"); - stale.last_accessed = Some(now - 10_000); - h.insert("stale-alive".into(), stale); - let reaped = h.reap_stale_unless(5_000, |sid| sid == "stale-alive"); - assert_eq!(reaped, 0); - assert!(h.get("stale-alive").is_some()); - } - - #[test] - fn nest_path_uses_resolver() { - let mut hum = blank_hum("1"); - hum.nest = vec![NestRef { - nest: "claude-cli".into(), - id: "n-1".into(), - }]; - hum.cwd = Some("/work".into()); - - let p = nest_path(&hum, |cwd, id| { - PathBuf::from(format!("{cwd}/.sessions/{id}.jsonl")) - }); - assert_eq!(p, Some(PathBuf::from("/work/.sessions/n-1.jsonl"))); - hum.cwd = None; - assert!(nest_path(&hum, |_, _| PathBuf::new()).is_none()); - } -} diff --git a/sim/src/lib.rs b/sim/src/lib.rs index 16e40d87..edb430a6 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -169,6 +169,7 @@ impl Sim { waneman: Some(waneman.clone()), humd_key: None, bootstrap_peers: Vec::new(), + thehum_cfg: None, }; let shutdown_fut = async move { @@ -304,6 +305,7 @@ impl Sim { waneman: Some(waneman.clone()), humd_key: None, bootstrap_peers: Vec::new(), + thehum_cfg: None, }; let shutdown_fut = async move { let _ = shutdown_rx.await; }; diff --git a/thehum/Cargo.toml b/thehum/Cargo.toml new file mode 100644 index 00000000..e42c0ea1 --- /dev/null +++ b/thehum/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "thehum" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Per-humd authored chi log. The sustained signal that captures everything the network ever did." + +[dependencies] +hum-paths = { path = "../hum-paths" } +ids = { path = "../ids" } +thrum-core = { path = "../thrum-core" } +ensemble = { path = "../ensemble" } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +ed25519-dalek = { version = "2", features = ["rand_core"] } +tokio = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +parking_lot = { workspace = true } +hex = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true, features = ["test-util"] } +rand = { workspace = true } diff --git a/thehum/README.md b/thehum/README.md new file mode 100644 index 00000000..bcbd2afa --- /dev/null +++ b/thehum/README.md @@ -0,0 +1,189 @@ +--- +title: "thehum" +description: "per-humd authored chi log — a signed append-only NDJSON ring of every event the humd observes or authors" +--- + +# thehum + +> _per-humd authored chi log — a signed append-only NDJSON ring of +> every event the humd observes or authors_ + +`thehum` is the sustained signal underneath a humd. Every chi that +crosses the daemon, whether the humd authored it or heard it from a +peer, lands here first as a signed, sequenced, hash-chained line. +Nothing else in hum is authoritative. `bees.json`, sid → bee routes, +session state, nest occupancy, ensemble's peer table — every one of +them is a projection rebuilt by replaying the log. + +## The architectural law + +> _identities and config are local; everything else is the log._ + +The humd's signing key lives on disk. Its `hum.json` lives on disk. +That is the entire local-only surface. State that anyone else might +ever care about — what happened in a bloom, which bee answered, what +the wane was, which tools fired — is a chi event in the log and +nowhere else. Crash, reboot, swap machines: open the log, `replay()`, +state returns. + +This makes the daemon a deterministic function of its log. It also +makes a humd's history portable: copy the directory, copy the key, +and the same humd lives on a new host. + +## Retention modes + +A humd chooses one of three modes in `hum.json`. The mode controls +what disk holds; the in-memory tail is always live. + +| mode | what it keeps | when to pick it | +|---|---|---| +| **archive** | every daily file, forever | research humds, on-chain anchors, audit operators | +| **rolling** | daily files within the last N days (default 30) plus all snapshots | the default — most desktop and server humds | +| **light** | only the latest daily file plus snapshots | phones, embedded boards, kiosk frontends | + +Older daily files are pruned by `enforce_retention()`, which is +called from a humd timer. Snapshots survive every mode; replay can +always seed from the most recent snapshot instead of from genesis. + +## Three flavors of write + +Every line in the log was written by one of these three paths. +They share the same on-disk shape; only the provenance differs. + +- **Authored.** This humd appends its own chi as the event happens. + `event.author` is this humd's hid; `sig` is over the canonical + bytes with the humd's key. +- **Replicated.** A peer's signed events arrive via `chi:"backfill"`. + Each event is verified locally — signature, hash chain, monotone + seq under that author — and then stored alongside the humd's own. +- **Snapshot.** Periodically, the humd hashes its current state-view + Merkle-style and emits a `chi:"snapshot"` event into the log + itself. Snapshots are ordinary events; replay treats them as a + resume point rather than a primitive. + +## Event shape + +One JSON object per line, NDJSON throughout. + +```json +{ + "chi": "prompt", + "sid": "", + "rid": "", + "body": { }, + "author": "humd_", + "seq": 102945, + "ts_ms": 1719793480123, + "prev_hash": "<32-byte hex>", + "sig": "" +} +``` + +`seq` is strictly monotonic per author. A gap is a missing event, +which is what triggers the next `chi:"backfill"`. `prev_hash` chains +each line to the canonical bytes of the previous one under the same +author — any tampered or reordered line fails verification. + +## Config + +`hum.json` — the `thehum` section: + +```json +{ + "mode": "rolling", + "days": 30, + "snapshot_every_events": 1000, + "snapshot_every_seconds": 600, + "encrypt_at_rest": false, + "fsync_per_event": true +} +``` + +| key | meaning | +|---|---| +| `mode` | `archive` \| `rolling` \| `light` | +| `days` | rolling-mode horizon, in days | +| `snapshot_every_events` | emit a snapshot after this many appends | +| `snapshot_every_seconds` | emit a snapshot at least this often | +| `encrypt_at_rest` | XChaCha20-Poly1305 with a key derived from the humd's signing key | +| `fsync_per_event` | flush every append; cost is durability vs. throughput | + +## API surface + +```rust +TheHum::open(dir, signing_key, Config) -> Result + +thehum.append(chi, sid, rid, body) -> Future +thehum.tail() -> broadcast::Receiver +thehum.range(author, from_seq) -> Vec +thehum.replay(handler) -> () +thehum.snapshot(state_leaves) -> StateRoot +thehum.enforce_retention() -> RetentionReport +thehum.anchor(&backend, &root, height) -> AnchorReceipt +``` + +`append` returns the seq assigned to the new event. `tail` is the +live broadcast every projection subscribes to. `range` answers a +`chi:"backfill"` from a peer. `replay` walks every stored event in +seq order, handing each one to a pure handler. + +## Determinism rule + +Materialized state derives purely from event content. + +```text +handler(prior_state, event) -> next_state +``` + +No `now()`, no `rand`, no env reads, no filesystem peeks. The +author's `ts_ms` is the only time source — set once at append, never +re-derived. Any humd replaying the same range of events arrives at +the same state. Two humds disagreeing on derived state means they +hold different events, not different code. + +## On-chain anchor + +When a humd is on a network that adopts an anchor contract, it can +periodically publish its state root for discoverability, trust-free +timestamping, and dispute settlement. + +```solidity +function anchor( + bytes32 hid, + bytes32 root, + uint256 height, + bytes calldata sig +) external; +``` + +Anchoring is a `trait AnchorBackend`; an `EvmAnchor` scaffold prepares +the calldata so a wallet (or a delegated signer) can submit the +transaction. The log doesn't sign chain transactions itself — it +hands the calldata up. The receipt comes back as a `chi:"anchor"` +event, written into the log like any other. + +## File layout + +``` +$XDG_STATE_HOME/hum/thehum/ + 2026-05-31.ndjson # today's authored + replicated events + 2026-05-30.ndjson # yesterday's + … + seq.bin # last-persisted seq counter + snapshots/ + .bin # one snapshot per height + root.txt # hex of the most recent state root +``` + +Daily files are the unit of retention; snapshots are the unit of +fast restart. Both are addressable by name alone, so a humd can +ship a single day, a single snapshot, or its whole history with no +extra index files. + +## See also + +- [`ensemble/`](../ensemble) — carries `chi:"backfill"` between humds. +- [`humd/`](../humd) — owns the `TheHum` instance and feeds it. +- [WIRE.md](../WIRE.md) — the chi values that ride the log. +- [`ids/`](../ids) — `HumId`, the content-addressed handle used for + authors and sids. diff --git a/thehum/src/anchor.rs b/thehum/src/anchor.rs new file mode 100644 index 00000000..0f630ffe --- /dev/null +++ b/thehum/src/anchor.rs @@ -0,0 +1,194 @@ +//! On-chain anchoring of state roots. +//! +//! `AnchorBackend` is implemented per network (Ethereum, Base, Arc). +//! `thehum::anchor()` posts the most recent state root + signed +//! attestation. Provides discoverability, timestamping, dispute +//! settlement, and reputation when a network chooses to adopt the +//! contract entry per humd. + +use anyhow::Result; +use async_trait::async_trait; + +use crate::{StateRoot, TheHum}; + +/// One on-chain anchor receipt. Returned by backends. +#[derive(Debug, Clone)] +pub struct AnchorReceipt { + pub network: String, + pub tx_hash: String, + pub block_number: Option, +} + +/// Per-network backend. `submit` posts the (hid, root, height) tuple +/// signed by the humd's signing key. Backends translate to whatever the +/// chain expects (EVM call, transaction, etc). +#[async_trait] +pub trait AnchorBackend: Send + Sync { + fn name(&self) -> &str; + async fn submit( + &self, + hid: &str, + root: &StateRoot, + height: u64, + sig_hex: &str, + ) -> Result; +} + +impl TheHum { + /// Anchor the most recent snapshot to the configured network. The + /// caller passes the same `root` that `snapshot()` returned plus + /// the height it was taken at. Signature uses thehum's signing key. + pub async fn anchor( + &self, + backend: &dyn AnchorBackend, + root: &StateRoot, + height: u64, + ) -> Result { + let payload = [ + self.author_hid.as_bytes(), + b":", + &height.to_be_bytes(), + b":", + root, + ].concat(); + let sig_hex = crate::sign::sign_canonical(&self.signing_key, &payload); + let receipt = backend.submit(&self.author_hid, root, height, &sig_hex).await?; + let body = serde_json::json!({ + "network": receipt.network, + "tx_hash": receipt.tx_hash, + "block_number": receipt.block_number, + "height": height, + "root": hex::encode(root), + }); + self.append("anchor", None, crate::HumId::mint(), body).await?; + Ok(receipt) + } +} + +/// Stub backend used by tests; submits to memory. +pub struct InMemoryAnchor { + pub name: String, + pub log: parking_lot::Mutex>, +} + +impl InMemoryAnchor { + pub fn new(name: impl Into) -> Self { + Self { name: name.into(), log: Default::default() } + } +} + +#[async_trait] +impl AnchorBackend for InMemoryAnchor { + fn name(&self) -> &str { &self.name } + + async fn submit( + &self, + hid: &str, + root: &StateRoot, + height: u64, + sig_hex: &str, + ) -> Result { + self.log.lock().push((hid.to_string(), *root, height, sig_hex.to_string())); + let n = self.log.lock().len(); + Ok(AnchorReceipt { + network: self.name.clone(), + tx_hash: format!("mem-{n}"), + block_number: Some(n as u64), + }) + } +} + +/// Ethereum/EVM backend scaffold. Wires up to a JSON-RPC endpoint via +/// reqwest and submits a typed call to a HumdRegistry contract: +/// +/// ```solidity +/// function anchor(bytes32 hid, bytes32 root, uint256 height, bytes calldata sig) external; +/// ``` +/// +/// v0 is a scaffold that prepares the calldata; real submission needs +/// a funded signer (the chain-side address) and is intentionally +/// deferred — production users wire their own wallet (Foundry script, +/// metamask-snap, etc) and call `prepare_calldata`. +pub struct EvmAnchor { + pub network: String, + pub registry_address: String, + pub rpc_url: String, +} + +impl EvmAnchor { + pub fn prepare_calldata(&self, hid: &str, root: &StateRoot, height: u64, sig_hex: &str) -> Vec { + let mut bytes = Vec::with_capacity(4 + 32 * 5); + bytes.extend_from_slice(&[0x4e, 0x71, 0xd9, 0x2d]); // placeholder selector + let mut hid_bytes = [0u8; 32]; + let raw = hex::decode(hid.trim_start_matches("humd_")).unwrap_or_default(); + let n = raw.len().min(32); + hid_bytes[..n].copy_from_slice(&raw[..n]); + bytes.extend_from_slice(&hid_bytes); + bytes.extend_from_slice(root); + let mut height_bytes = [0u8; 32]; + height_bytes[24..].copy_from_slice(&height.to_be_bytes()); + bytes.extend_from_slice(&height_bytes); + let mut offset = [0u8; 32]; + offset[31] = 0xa0; + bytes.extend_from_slice(&offset); + let sig_bytes = hex::decode(sig_hex).unwrap_or_default(); + let mut sig_len = [0u8; 32]; + sig_len[24..].copy_from_slice(&(sig_bytes.len() as u64).to_be_bytes()); + bytes.extend_from_slice(&sig_len); + let padded_len = (sig_bytes.len() + 31) & !31; + let mut padded = vec![0u8; padded_len]; + padded[..sig_bytes.len()].copy_from_slice(&sig_bytes); + bytes.extend_from_slice(&padded); + bytes + } +} + +#[async_trait] +impl AnchorBackend for EvmAnchor { + fn name(&self) -> &str { &self.network } + + async fn submit( + &self, + _hid: &str, + _root: &StateRoot, + _height: u64, + _sig_hex: &str, + ) -> Result { + anyhow::bail!("EvmAnchor: v0 ships calldata-prep only; production users wire their own signer. Call prepare_calldata() and submit via your wallet.") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + use tempfile::TempDir; + + #[tokio::test] + async fn in_memory_anchor_records_and_appends() { + let tmp = TempDir::new().unwrap(); + let key = SigningKey::generate(&mut OsRng); + let t = TheHum::open(tmp.path(), key, crate::Config::default()).unwrap(); + let backend = InMemoryAnchor::new("test"); + let root: StateRoot = [9u8; 32]; + let receipt = t.anchor(&backend, &root, 42).await.unwrap(); + assert_eq!(receipt.network, "test"); + assert_eq!(backend.log.lock().len(), 1); + + let events = t.range(t.author_hid(), 0).unwrap(); + assert_eq!(events.last().unwrap().chi, "anchor"); + } + + #[test] + fn evm_calldata_is_padded_correctly() { + let evm = EvmAnchor { + network: "evm-test".into(), + registry_address: "0x0".into(), + rpc_url: "http://localhost:8545".into(), + }; + let data = evm.prepare_calldata("humd_aabbcc", &[1u8; 32], 100, "deadbeef"); + assert!(data.len() >= 196, "selector(4) + 5×32-byte words + padded dynamic data"); + assert_eq!(data.len() % 32, 4, "post-selector tail aligns to 32-byte words"); + } +} diff --git a/thehum/src/append.rs b/thehum/src/append.rs new file mode 100644 index 00000000..6e34a869 --- /dev/null +++ b/thehum/src/append.rs @@ -0,0 +1,193 @@ +//! Write path: append, fsync, seq.bin atomic update, hash chain advance. +//! +//! Single appender per humd. Concurrent readers tail via the broadcast +//! channel in TheHum::live_tx; range scans hit the files directly. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde_json::Value; + +use crate::{canon, sign, Event, Hash32, Seq, TheHum}; + +impl TheHum { + /// Append a wire envelope as a new authored event. Signs, chains, + /// fsyncs, broadcasts. Returns the assigned seq. + pub async fn append(&self, chi: &str, sid: Option, rid: crate::HumId, body: Value) -> Result { + let ts_ms = chrono::Utc::now().timestamp_millis(); + let (seq, prev_hash_hex) = { + let mut s = self.state.lock(); + s.seq += 1; + (s.seq, hex::encode(s.prev_hash)) + }; + + let mut event = Event { + chi: chi.to_string(), + sid, + rid, + body, + author: self.author_hid.clone(), + seq, + ts_ms, + prev_hash: prev_hash_hex, + sig: String::new(), + }; + let canonical = canon::canonical_bytes_of(&event); + event.sig = sign::sign_canonical(&self.signing_key, &canonical); + + let this_hash: Hash32 = sign::hash256(&canonical); + + let line = serde_json::to_string(&event).context("serialize event")?; + let path = daily_path(&self.dir, ts_ms); + write_line(&path, &line, self.cfg.fsync_per_event)?; + persist_seq(&self.dir, seq)?; + + { + let mut s = self.state.lock(); + s.prev_hash = this_hash; + } + + let _ = self.live_tx.send(event.clone()); + + tracing::trace!(target: "thehum", %seq, %chi, "appended"); + Ok(seq) + } +} + +/// Path of the daily ring for the given ts. +pub(crate) fn daily_path(dir: &Path, ts_ms: i64) -> PathBuf { + let dt = DateTime::::from_timestamp_millis(ts_ms) + .unwrap_or_else(|| DateTime::::from_timestamp_millis(0).unwrap()); + let day = dt.format("%Y-%m-%d"); + dir.join(format!("{day}.ndjson")) +} + +fn write_line(path: &Path, line: &str, fsync: bool) -> Result<()> { + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("open {}", path.display()))?; + f.write_all(line.as_bytes())?; + f.write_all(b"\n")?; + if fsync { + f.sync_data().context("fsync")?; + } + Ok(()) +} + +/// Atomic seq persistence: tmp + rename. +fn persist_seq(dir: &Path, seq: Seq) -> Result<()> { + let final_path = crate::layout::seq_file(dir); + let tmp = final_path.with_extension("bin.tmp"); + std::fs::write(&tmp, seq.to_le_bytes())?; + std::fs::rename(&tmp, &final_path).context("rename seq.bin")?; + Ok(()) +} + +/// Cold-boot recovery: read seq.bin and the last line's pre-sig hash. +pub fn recover_state(dir: &Path) -> Result<(Seq, Hash32)> { + let seq = std::fs::read(crate::layout::seq_file(dir)) + .ok() + .and_then(|b| { + if b.len() == 8 { + let mut arr = [0u8; 8]; + arr.copy_from_slice(&b); + Some(u64::from_le_bytes(arr)) + } else { + None + } + }) + .unwrap_or(0); + + let mut prev_hash: Hash32 = [0u8; 32]; + if seq > 0 { + if let Some(line) = last_line_in_dir(dir)? { + let parsed: Value = serde_json::from_str(&line).context("parse last line")?; + let canonical = canon::canonical_bytes(&parsed); + prev_hash = sign::hash256(&canonical); + } + } + Ok((seq, prev_hash)) +} + +fn last_line_in_dir(dir: &Path) -> Result> { + let mut files: Vec<_> = std::fs::read_dir(dir) + .context("readdir thehum")? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ndjson")) + .collect(); + files.sort(); + for path in files.iter().rev() { + if let Ok(content) = std::fs::read_to_string(path) { + if let Some(last) = content.lines().filter(|l| !l.trim().is_empty()).last() { + return Ok(Some(last.to_string())); + } + } + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + use serde_json::json; + use tempfile::TempDir; + + fn mk_thehum(dir: &Path) -> TheHum { + let key = SigningKey::generate(&mut OsRng); + TheHum::open(dir, key, crate::Config::default()).unwrap() + } + + #[tokio::test] + async fn append_assigns_monotonic_seq_and_advances_chain() { + let tmp = TempDir::new().unwrap(); + let t = mk_thehum(tmp.path()); + let rid = crate::HumId::mint(); + let s1 = t.append("prompt", None, rid.clone(), json!({"text": "hi"})).await.unwrap(); + let s2 = t.append("chunk", None, rid.clone(), json!({"delta": "ok"})).await.unwrap(); + let s3 = t.append("finish", None, rid, json!({"reason": "end"})).await.unwrap(); + assert_eq!((s1, s2, s3), (1, 2, 3)); + } + + #[tokio::test] + async fn cold_recover_picks_up_where_we_left_off() { + let tmp = TempDir::new().unwrap(); + let key = SigningKey::generate(&mut OsRng); + let first = TheHum::open(tmp.path(), key.clone(), crate::Config::default()).unwrap(); + let rid = crate::HumId::mint(); + first.append("hello", None, rid.clone(), json!({})).await.unwrap(); + first.append("prompt", None, rid, json!({"text": "x"})).await.unwrap(); + drop(first); + + let second = TheHum::open(tmp.path(), key, crate::Config::default()).unwrap(); + let s = second.state.lock(); + assert_eq!(s.seq, 2); + assert_ne!(s.prev_hash, [0u8; 32], "prev_hash recovered from last line"); + } + + #[tokio::test] + async fn chain_links_are_consistent() { + let tmp = TempDir::new().unwrap(); + let t = mk_thehum(tmp.path()); + let rid = crate::HumId::mint(); + t.append("a", None, rid.clone(), json!({})).await.unwrap(); + t.append("b", None, rid.clone(), json!({})).await.unwrap(); + + let lines: Vec = std::fs::read_to_string(tmp.path().join( + crate::append::daily_path(tmp.path(), chrono::Utc::now().timestamp_millis()) + .file_name().unwrap() + )).unwrap().lines().map(String::from).collect(); + let e0: Value = serde_json::from_str(&lines[0]).unwrap(); + let e1: Value = serde_json::from_str(&lines[1]).unwrap(); + let h0 = sign::hash256(&canon::canonical_bytes(&e0)); + let claimed = hex::decode(e1["prev_hash"].as_str().unwrap()).unwrap(); + assert_eq!(claimed.as_slice(), &h0[..]); + } +} diff --git a/thehum/src/canon.rs b/thehum/src/canon.rs new file mode 100644 index 00000000..a17e7289 --- /dev/null +++ b/thehum/src/canon.rs @@ -0,0 +1,76 @@ +//! Canonical serialization for hashing + signing. +//! +//! Determinism rule: two peers serializing the same logical Event must +//! produce identical bytes. `serde_json` doesn't sort keys by default, +//! so we walk the Value, copy maps into BTreeMap (alphabetic), and +//! re-emit as compact JSON. + +use serde_json::Value; +use std::collections::BTreeMap; + +/// Sorted-key canonical JSON bytes for the event MINUS the `sig` field. +/// Hash + signature are computed over this output. +pub fn canonical_bytes(event_without_sig: &Value) -> Vec { + let sorted = sort_value(event_without_sig); + serde_json::to_vec(&sorted).expect("canonical serialization") +} + +fn sort_value(v: &Value) -> Value { + match v { + Value::Object(map) => { + let mut sorted = BTreeMap::new(); + for (k, vv) in map { + if k == "sig" { continue; } + sorted.insert(k.clone(), sort_value(vv)); + } + Value::Object(sorted.into_iter().collect()) + } + Value::Array(arr) => Value::Array(arr.iter().map(sort_value).collect()), + other => other.clone(), + } +} + +/// Same shape but for an Event built in-code (not parsed from JSON). +/// Excludes `sig` from the hash + sign domain. +pub fn canonical_bytes_of(event: &crate::Event) -> Vec { + let v = serde_json::to_value(event).expect("event → value"); + canonical_bytes(&v) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn map_key_order_is_normalized() { + let a = canonical_bytes(&json!({"b": 1, "a": 2, "c": 3})); + let b = canonical_bytes(&json!({"a": 2, "c": 3, "b": 1})); + let c = canonical_bytes(&json!({"c": 3, "b": 1, "a": 2})); + assert_eq!(a, b); + assert_eq!(b, c); + } + + #[test] + fn nested_maps_sorted() { + let a = canonical_bytes(&json!({"x": {"b": 1, "a": 2}})); + let b = canonical_bytes(&json!({"x": {"a": 2, "b": 1}})); + assert_eq!(a, b); + } + + #[test] + fn sig_is_stripped() { + let with_sig = canonical_bytes(&json!({"a": 1, "sig": "deadbeef"})); + let without = canonical_bytes(&json!({"a": 1})); + assert_eq!(with_sig, without); + } + + #[test] + fn arrays_preserve_order() { + let a = canonical_bytes(&json!([3, 1, 2])); + let b = canonical_bytes(&json!([3, 1, 2])); + let c = canonical_bytes(&json!([1, 2, 3])); + assert_eq!(a, b); + assert_ne!(a, c); + } +} diff --git a/thehum/src/layout.rs b/thehum/src/layout.rs new file mode 100644 index 00000000..5ea9a62e --- /dev/null +++ b/thehum/src/layout.rs @@ -0,0 +1,8 @@ +//! Single source of truth for thehum's on-disk filenames. + +use std::path::{Path, PathBuf}; + +pub fn seq_file(dir: &Path) -> PathBuf { dir.join("seq.bin") } +pub fn snapshots_dir(dir: &Path) -> PathBuf { dir.join("snapshots") } +pub fn root_file(dir: &Path) -> PathBuf { dir.join("root.txt") } +pub fn ndjson_ext() -> &'static str { "ndjson" } diff --git a/thehum/src/lib.rs b/thehum/src/lib.rs new file mode 100644 index 00000000..30354205 --- /dev/null +++ b/thehum/src/lib.rs @@ -0,0 +1,177 @@ +//! `thehum` — per-humd authored chi log. +//! +//! Every humd maintains a signed append-only log of every chi it observes. +//! The log is the only authoritative store of activity; everything else +//! (bees.json, route tables, sid state) is a derived view. +//! +//! Event shape: chi envelope + author hid + monotonic seq + ts_ms + +//! prev_hash (chain) + ed25519 signature. +//! +//! Three flavors of participation: +//! - Archive — keep all logs forever +//! - Rolling — drop daily files older than N days +//! - Light — keep snapshots + own-sids only + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use ed25519_dalek::SigningKey; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; + +pub mod anchor; +pub mod append; +pub mod canon; +pub mod layout; +pub mod read; +pub mod retention; +pub mod sign; +pub mod snapshot; + +pub use ed25519_dalek::{SigningKey as Key, VerifyingKey as PubKey}; +pub use ids::HumId; + +pub type Seq = u64; +pub type Hash32 = [u8; 32]; +pub type StateRoot = Hash32; + +/// One canonical chi-log line: envelope + author + chain + sig. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Event { + /// The chi kind (e.g. "hello", "prompt", "chunk", "tool-call", + /// "finish", "snapshot", "backfill"). + pub chi: String, + /// Session id this event belongs to. None for events that don't + /// scope to a sid (some "hello", network-wide "snapshot"). + #[serde(skip_serializing_if = "Option::is_none")] + pub sid: Option, + /// Correlation id (the rid in chi envelopes). + pub rid: HumId, + /// The rest of the wire body, verbatim. + pub body: serde_json::Value, + /// Author humd's hid hex form. + pub author: String, + /// Strictly monotonic per author. Gap = missing event. + pub seq: Seq, + /// Wall-clock at append time, ms since epoch. Reading code uses + /// this, NEVER `now()`. + pub ts_ms: i64, + /// Hex of sha256 over the prior event's canonical bytes. Chain + /// integrity: any tampering invalidates downstream hashes. + pub prev_hash: String, + /// Hex of 64-byte ed25519 signature over the canonical pre-sig + /// bytes of THIS event. + pub sig: String, +} + +/// Retention mode for this humd's chi log. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RetentionMode { + Archive, + Rolling, + Light, +} + +impl Default for RetentionMode { + fn default() -> Self { Self::Archive } +} + +/// Persistence configuration. Read from `hum.json` `thehum` section. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub mode: RetentionMode, + /// For Rolling mode: drop daily files older than this. + #[serde(default = "default_days")] + pub days: u32, + /// Snapshot every N events (whichever fires first). + #[serde(default = "default_snapshot_events")] + pub snapshot_every_events: u64, + /// Snapshot every N seconds (whichever fires first). + #[serde(default = "default_snapshot_seconds")] + pub snapshot_every_seconds: u64, + /// Encrypt-at-rest (v0: not wired; field reserved). + #[serde(default)] + pub encrypt_at_rest: bool, + /// fsync per event (true) or batched (false). Per-event by default. + #[serde(default = "default_fsync")] + pub fsync_per_event: bool, +} + +fn default_days() -> u32 { 30 } +fn default_snapshot_events() -> u64 { 1000 } +fn default_snapshot_seconds() -> u64 { 600 } +fn default_fsync() -> bool { true } + +impl Default for Config { + fn default() -> Self { + Self { + mode: RetentionMode::default(), + days: default_days(), + snapshot_every_events: default_snapshot_events(), + snapshot_every_seconds: default_snapshot_seconds(), + encrypt_at_rest: false, + fsync_per_event: default_fsync(), + } + } +} + +/// The per-humd chi-log handle. Single appender, many readers. +pub struct TheHum { + pub(crate) dir: PathBuf, + pub(crate) signing_key: Arc, + pub(crate) author_hid: String, + pub(crate) cfg: Config, + pub(crate) state: Arc>, + pub(crate) live_tx: broadcast::Sender, +} + +pub(crate) struct AppendState { + pub seq: Seq, + pub prev_hash: Hash32, + pub last_snapshot_seq: Seq, + pub last_snapshot_ts_ms: i64, +} + +impl TheHum { + /// Open (or initialize) thehum at `dir`. Loads seq.bin, last-line + /// hash, snapshot pointer. Mints the dir on first run. + pub fn open(dir: &Path, signing_key: SigningKey, cfg: Config) -> Result { + std::fs::create_dir_all(dir) + .with_context(|| format!("create thehum dir {}", dir.display()))?; + std::fs::create_dir_all(layout::snapshots_dir(dir)) + .with_context(|| format!("create snapshots dir {}", layout::snapshots_dir(dir).display()))?; + + let pubkey = signing_key.verifying_key(); + let author_hid = ensemble::Hid::from_pubkey( + ensemble::HidPrefix::Humd, + &pubkey.to_bytes(), + ).to_hex(); + + let (seq, prev_hash) = append::recover_state(dir) + .context("recover append state")?; + + let (live_tx, _rx) = broadcast::channel::(1024); + + Ok(Self { + dir: dir.to_path_buf(), + signing_key: Arc::new(signing_key), + author_hid, + cfg, + state: Arc::new(Mutex::new(AppendState { + seq, + prev_hash, + last_snapshot_seq: 0, + last_snapshot_ts_ms: 0, + })), + live_tx, + }) + } + + pub fn author_hid(&self) -> &str { &self.author_hid } + pub fn dir(&self) -> &Path { &self.dir } + pub fn cfg(&self) -> &Config { &self.cfg } +} diff --git a/thehum/src/read.rs b/thehum/src/read.rs new file mode 100644 index 00000000..c1360448 --- /dev/null +++ b/thehum/src/read.rs @@ -0,0 +1,150 @@ +//! Read paths: tail (live), range (file scan), replay (boot reconstruct). + +use std::path::Path; + +use anyhow::{Context, Result}; +use serde_json::Value; +use tokio::sync::broadcast; + +use crate::{Event, Seq, TheHum}; + +impl TheHum { + /// Subscribe to new events as they're appended. Lagged subscribers + /// see a `RecvError::Lagged(n)` if they fall behind by >1024 events. + pub fn tail(&self) -> broadcast::Receiver { + self.live_tx.subscribe() + } + + /// Stream the historical range [from_seq, ..) for `author`. Includes + /// events authored by this humd from the local log. (Other authors' + /// events arrive via gossip; range over the local log is the canonical + /// answer for THIS humd's history.) + pub fn range(&self, author: &str, from_seq: Seq) -> Result> { + let mut events = scan_all(&self.dir).context("scan log files")?; + events.retain(|e| e.author == author && e.seq >= from_seq); + events.sort_by_key(|e| e.seq); + Ok(events) + } + + /// Replay every event in this humd's log (in seq order) through + /// `handler`. Use to rebuild materialized views on cold-boot. + /// Handler must be PURE: no clocks, no rng, no env reads — use + /// `event.ts_ms` for any time-shaped value. + pub fn replay(&self, mut handler: F) -> Result<()> { + let mut events = scan_all(&self.dir).context("scan log files")?; + events.sort_by_key(|e| (e.author.clone(), e.seq)); + for e in &events { + handler(e); + } + Ok(()) + } +} + +fn scan_all(dir: &Path) -> Result> { + let mut files: Vec<_> = std::fs::read_dir(dir) + .context("readdir thehum")? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ndjson")) + .collect(); + files.sort(); + + let mut out = Vec::new(); + for path in &files { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { continue; } + match serde_json::from_str::(line) { + Ok(ev) => out.push(ev), + Err(e) => tracing::warn!(target: "thehum", path = %path.display(), err = %e, "skip malformed line"), + } + } + } + Ok(out) +} + +/// Verify every event in a vec — signature valid, prev_hash chains, +/// seqs monotonic per author. Returns first violation or Ok(()). +pub fn verify_chain(events: &[Event], pubkey: &ed25519_dalek::VerifyingKey) -> Result<()> { + let mut last_hash_per_author: std::collections::HashMap = Default::default(); + let mut last_seq_per_author: std::collections::HashMap = Default::default(); + for e in events { + let v: Value = serde_json::to_value(e)?; + let canonical = crate::canon::canonical_bytes(&v); + crate::sign::verify_canonical(pubkey, &canonical, &e.sig) + .with_context(|| format!("sig invalid at seq {}", e.seq))?; + let expected_prev = last_hash_per_author.get(&e.author).copied().unwrap_or([0u8; 32]); + let claimed: Vec = hex::decode(&e.prev_hash).context("prev_hash hex")?; + anyhow::ensure!(claimed == expected_prev, "chain break at seq {}", e.seq); + let expected_seq = last_seq_per_author.get(&e.author).copied().unwrap_or(0) + 1; + anyhow::ensure!(e.seq == expected_seq, "seq gap at {}: expected {}", e.seq, expected_seq); + last_seq_per_author.insert(e.author.clone(), e.seq); + last_hash_per_author.insert(e.author.clone(), crate::sign::hash256(&canonical)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + use serde_json::json; + use tempfile::TempDir; + + #[tokio::test] + async fn replay_visits_events_in_seq_order() { + let tmp = TempDir::new().unwrap(); + let key = SigningKey::generate(&mut OsRng); + let t = TheHum::open(tmp.path(), key, crate::Config::default()).unwrap(); + let rid = crate::HumId::mint(); + for i in 0..5 { + t.append("e", None, rid.clone(), json!({"i": i})).await.unwrap(); + } + let mut seen = Vec::new(); + t.replay(|e| seen.push(e.seq)).unwrap(); + assert_eq!(seen, vec![1, 2, 3, 4, 5]); + } + + #[tokio::test] + async fn range_returns_from_seq() { + let tmp = TempDir::new().unwrap(); + let key = SigningKey::generate(&mut OsRng); + let t = TheHum::open(tmp.path(), key, crate::Config::default()).unwrap(); + let rid = crate::HumId::mint(); + for _ in 0..5 { + t.append("e", None, rid.clone(), json!({})).await.unwrap(); + } + let r = t.range(t.author_hid(), 3).unwrap(); + assert_eq!(r.iter().map(|e| e.seq).collect::>(), vec![3, 4, 5]); + } + + #[tokio::test] + async fn tail_receives_subsequent_events() { + let tmp = TempDir::new().unwrap(); + let key = SigningKey::generate(&mut OsRng); + let t = TheHum::open(tmp.path(), key, crate::Config::default()).unwrap(); + let mut rx = t.tail(); + let rid = crate::HumId::mint(); + t.append("e", None, rid, json!({})).await.unwrap(); + let ev = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()).await.unwrap().unwrap(); + assert_eq!(ev.seq, 1); + } + + #[tokio::test] + async fn verify_chain_passes_on_clean_log() { + let tmp = TempDir::new().unwrap(); + let key = SigningKey::generate(&mut OsRng); + let t = TheHum::open(tmp.path(), key.clone(), crate::Config::default()).unwrap(); + let rid = crate::HumId::mint(); + for _ in 0..3 { + t.append("e", None, rid.clone(), json!({})).await.unwrap(); + } + let events = t.range(t.author_hid(), 0).unwrap(); + verify_chain(&events, &key.verifying_key()).expect("clean log verifies"); + } +} diff --git a/thehum/src/retention.rs b/thehum/src/retention.rs new file mode 100644 index 00000000..f297998c --- /dev/null +++ b/thehum/src/retention.rs @@ -0,0 +1,122 @@ +//! Retention enforcement. Run periodically; obey the configured mode. + +use std::path::Path; + +use anyhow::{Context, Result}; +use chrono::{Duration, NaiveDate, Utc}; + +use crate::{RetentionMode, TheHum}; + +impl TheHum { + /// Apply retention policy. Idempotent. Safe to call repeatedly. + pub fn enforce_retention(&self) -> Result { + match self.cfg.mode { + RetentionMode::Archive => Ok(RetentionReport::default()), + RetentionMode::Rolling => self.prune_older_than_days(self.cfg.days), + RetentionMode::Light => self.prune_to_snapshots_only(), + } + } + + fn prune_older_than_days(&self, days: u32) -> Result { + let cutoff = (Utc::now() - Duration::days(days as i64)).date_naive(); + let mut report = RetentionReport::default(); + for path in daily_files(&self.dir)? { + let Some(file_day) = parse_ndjson_date(&path) else { continue }; + if file_day < cutoff { + std::fs::remove_file(&path) + .with_context(|| format!("remove {}", path.display()))?; + report.removed_files += 1; + } else { + report.kept_files += 1; + } + } + Ok(report) + } + + fn prune_to_snapshots_only(&self) -> Result { + let mut files = daily_files(&self.dir)?; + files.sort(); + let keep = files.pop(); + let mut report = RetentionReport::default(); + for path in files { + std::fs::remove_file(&path)?; + report.removed_files += 1; + } + if keep.is_some() { report.kept_files = 1; } + Ok(report) + } +} + +#[derive(Debug, Clone, Default)] +pub struct RetentionReport { + pub removed_files: u32, + pub kept_files: u32, +} + +fn daily_files(dir: &Path) -> Result> { + Ok(std::fs::read_dir(dir)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ndjson")) + .collect()) +} + +fn parse_ndjson_date(path: &Path) -> Option { + let stem = path.file_stem()?.to_str()?; + NaiveDate::parse_from_str(stem, "%Y-%m-%d").ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + use tempfile::TempDir; + + fn mk(dir: &Path, mode: RetentionMode) -> TheHum { + let key = SigningKey::generate(&mut OsRng); + let mut cfg = crate::Config::default(); + cfg.mode = mode; + cfg.days = 7; + TheHum::open(dir, key, cfg).unwrap() + } + + #[test] + fn archive_keeps_everything() { + let tmp = TempDir::new().unwrap(); + for day in &["2020-01-01", "2024-06-15", "2026-05-31"] { + std::fs::write(tmp.path().join(format!("{day}.ndjson")), "").unwrap(); + } + let t = mk(tmp.path(), RetentionMode::Archive); + let r = t.enforce_retention().unwrap(); + assert_eq!(r.removed_files, 0); + assert_eq!(std::fs::read_dir(tmp.path()).unwrap().filter(|e| { + e.as_ref().unwrap().path().extension().and_then(|x| x.to_str()) == Some("ndjson") + }).count(), 3); + } + + #[test] + fn rolling_drops_old_days() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("2020-01-01.ndjson"), "").unwrap(); + std::fs::write(tmp.path().join("2020-01-02.ndjson"), "").unwrap(); + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + std::fs::write(tmp.path().join(format!("{today}.ndjson")), "").unwrap(); + let t = mk(tmp.path(), RetentionMode::Rolling); + let r = t.enforce_retention().unwrap(); + assert_eq!(r.removed_files, 2); + assert_eq!(r.kept_files, 1); + } + + #[test] + fn light_keeps_only_most_recent() { + let tmp = TempDir::new().unwrap(); + for day in &["2020-01-01", "2024-06-15", "2026-05-31"] { + std::fs::write(tmp.path().join(format!("{day}.ndjson")), "").unwrap(); + } + let t = mk(tmp.path(), RetentionMode::Light); + let r = t.enforce_retention().unwrap(); + assert_eq!(r.removed_files, 2); + assert_eq!(r.kept_files, 1); + } +} diff --git a/thehum/src/sign.rs b/thehum/src/sign.rs new file mode 100644 index 00000000..424da83d --- /dev/null +++ b/thehum/src/sign.rs @@ -0,0 +1,68 @@ +//! ed25519 signing + verification over canonical event bytes. + +use anyhow::{anyhow, Context, Result}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use sha2::{Digest, Sha256}; + +use crate::Hash32; + +/// Sign canonical bytes; return hex-encoded 64-byte signature. +pub fn sign_canonical(key: &SigningKey, canonical: &[u8]) -> String { + let sig: Signature = key.sign(canonical); + hex::encode(sig.to_bytes()) +} + +/// Verify a signature over canonical bytes against a pubkey. +pub fn verify_canonical(pubkey: &VerifyingKey, canonical: &[u8], sig_hex: &str) -> Result<()> { + let sig_bytes = hex::decode(sig_hex).context("decode sig hex")?; + let sig = Signature::from_slice(&sig_bytes).map_err(|e| anyhow!("sig parse: {e}"))?; + pubkey.verify(canonical, &sig).map_err(|e| anyhow!("verify: {e}")) +} + +/// sha256 of any bytes → 32-byte hash. Used for prev_hash chaining. +pub fn hash256(bytes: &[u8]) -> Hash32 { + let mut h = Sha256::new(); + h.update(bytes); + let digest = h.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest[..32]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::OsRng; + + #[test] + fn sign_verify_roundtrip() { + let mut rng = OsRng; + let key = SigningKey::generate(&mut rng); + let msg = b"some canonical event bytes"; + let sig = sign_canonical(&key, msg); + verify_canonical(&key.verifying_key(), msg, &sig).expect("verify ok"); + } + + #[test] + fn verify_rejects_tampered_bytes() { + let mut rng = OsRng; + let key = SigningKey::generate(&mut rng); + let sig = sign_canonical(&key, b"original"); + assert!(verify_canonical(&key.verifying_key(), b"tampered", &sig).is_err()); + } + + #[test] + fn verify_rejects_wrong_key() { + let mut rng = OsRng; + let k1 = SigningKey::generate(&mut rng); + let k2 = SigningKey::generate(&mut rng); + let sig = sign_canonical(&k1, b"msg"); + assert!(verify_canonical(&k2.verifying_key(), b"msg", &sig).is_err()); + } + + #[test] + fn hash256_is_deterministic() { + assert_eq!(hash256(b"hello"), hash256(b"hello")); + assert_ne!(hash256(b"hello"), hash256(b"world")); + } +} diff --git a/thehum/src/snapshot.rs b/thehum/src/snapshot.rs new file mode 100644 index 00000000..de30ff95 --- /dev/null +++ b/thehum/src/snapshot.rs @@ -0,0 +1,142 @@ +//! Snapshots + Merkle root commitments. +//! +//! Periodically the humd computes a Merkle root over its materialized +//! state and emits it as a `chi:"snapshot"` event into its own log. +//! Light peers can sync the root chain without holding full state. + +use std::collections::BTreeMap; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use crate::{sign, Hash32, StateRoot, TheHum}; + +/// One leaf of the state Merkle tree. Materialized views package +/// themselves as (key → bytes) maps before commitment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateLeaf { + pub key: String, + pub value: serde_json::Value, +} + +/// Build a deterministic Merkle root over a BTreeMap of state leaves. +/// Sorted by key. Tree is binary; odd levels duplicate the last node. +/// Empty tree → all-zero root. +pub fn merkle_root(leaves: &BTreeMap) -> StateRoot { + if leaves.is_empty() { + return [0u8; 32]; + } + let mut level: Vec = leaves + .iter() + .map(|(k, v)| { + let mut bytes = Vec::new(); + bytes.extend_from_slice(k.as_bytes()); + bytes.push(0); + bytes.extend_from_slice(&crate::canon::canonical_bytes(v)); + sign::hash256(&bytes) + }) + .collect(); + + while level.len() > 1 { + let mut next = Vec::with_capacity(level.len().div_ceil(2)); + let mut i = 0; + while i < level.len() { + let l = level[i]; + let r = if i + 1 < level.len() { level[i + 1] } else { level[i] }; + let mut combined = [0u8; 64]; + combined[..32].copy_from_slice(&l); + combined[32..].copy_from_slice(&r); + next.push(sign::hash256(&combined)); + i += 2; + } + level = next; + } + level[0] +} + +impl TheHum { + /// Emit a snapshot event for the current materialized state. + /// `state_leaves` is the projection that THIS humd is committing to. + /// Call after applying recent events; the committed root is + /// independent of the chi log itself. + pub async fn snapshot(&self, state_leaves: BTreeMap) -> Result { + let root = merkle_root(&state_leaves); + let height = { + let s = self.state.lock(); + s.seq + }; + let body = serde_json::json!({ + "root": hex::encode(root), + "height": height, + "leaves": state_leaves.len(), + }); + self.append("snapshot", None, crate::HumId::mint(), body).await?; + { + let mut s = self.state.lock(); + s.last_snapshot_seq = height; + s.last_snapshot_ts_ms = chrono::Utc::now().timestamp_millis(); + } + Ok(root) + } + + /// True when the snapshot cadence policy says we should snapshot. + /// Cheap; meant to be called per-append or periodically. + pub fn should_snapshot(&self, now_ms: i64) -> bool { + let s = self.state.lock(); + let events_since = s.seq.saturating_sub(s.last_snapshot_seq); + let ms_since = now_ms.saturating_sub(s.last_snapshot_ts_ms); + events_since >= self.cfg.snapshot_every_events + || ms_since >= (self.cfg.snapshot_every_seconds as i64) * 1000 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + use serde_json::json; + use tempfile::TempDir; + + #[test] + fn empty_tree_is_zero_root() { + let root = merkle_root(&BTreeMap::new()); + assert_eq!(root, [0u8; 32]); + } + + #[test] + fn root_is_deterministic_across_insert_order() { + let mut a = BTreeMap::new(); + a.insert("bee_b".into(), json!({"hid": "b", "online": true})); + a.insert("bee_a".into(), json!({"hid": "a", "online": true})); + let mut b = BTreeMap::new(); + b.insert("bee_a".into(), json!({"hid": "a", "online": true})); + b.insert("bee_b".into(), json!({"hid": "b", "online": true})); + assert_eq!(merkle_root(&a), merkle_root(&b)); + } + + #[test] + fn root_changes_on_any_leaf_change() { + let mut a = BTreeMap::new(); + a.insert("k".into(), json!({"v": 1})); + let mut b = BTreeMap::new(); + b.insert("k".into(), json!({"v": 2})); + assert_ne!(merkle_root(&a), merkle_root(&b)); + } + + #[tokio::test] + async fn snapshot_appends_to_log() { + let tmp = TempDir::new().unwrap(); + let key = SigningKey::generate(&mut OsRng); + let t = TheHum::open(tmp.path(), key, crate::Config::default()).unwrap(); + let rid = crate::HumId::mint(); + t.append("e", None, rid, json!({})).await.unwrap(); + let mut state = BTreeMap::new(); + state.insert("sid1".into(), json!({"bees": ["w1"]})); + let root = t.snapshot(state).await.unwrap(); + assert_ne!(root, [0u8; 32]); + + let events = t.range(t.author_hid(), 0).unwrap(); + assert_eq!(events.last().unwrap().chi, "snapshot"); + } +} diff --git a/thehum/tests/end_to_end.rs b/thehum/tests/end_to_end.rs new file mode 100644 index 00000000..c5868f11 --- /dev/null +++ b/thehum/tests/end_to_end.rs @@ -0,0 +1,285 @@ +//! End-to-end integration tests for thehum. +//! +//! Each test drives the public API as a real consumer would: open a +//! dir, append events, drop, reopen, replay, verify. Goal is to prove +//! the protocol holds together end-to-end, not exercise unit-level +//! corners (those live in src/* tests). + +use std::collections::{BTreeMap, HashMap}; + +use ed25519_dalek::SigningKey; +use rand::rngs::OsRng; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tokio::sync::broadcast::error::TryRecvError; + +use thehum::{read::verify_chain, Config, HumId, RetentionMode, TheHum}; + +fn fresh_key() -> SigningKey { + SigningKey::generate(&mut OsRng) +} + +#[tokio::test] +async fn full_session_lifecycle_replays_to_same_state() { + let tmp = TempDir::new().unwrap(); + let key = fresh_key(); + let sid = HumId::mint(); + + let originals = { + let t = TheHum::open(tmp.path(), key.clone(), Config::default()).unwrap(); + let rid_prompt = HumId::mint(); + let rid_tool = HumId::mint(); + + t.append("hello", Some(sid.clone()), HumId::mint(), json!({"v": 1})) + .await + .unwrap(); + t.append("prompt", Some(sid.clone()), rid_prompt.clone(), json!({"text": "hi there"})) + .await + .unwrap(); + t.append("chunk", Some(sid.clone()), rid_prompt.clone(), json!({"delta": "he"})) + .await + .unwrap(); + t.append("chunk", Some(sid.clone()), rid_prompt.clone(), json!({"delta": "llo"})) + .await + .unwrap(); + t.append("chunk", Some(sid.clone()), rid_prompt.clone(), json!({"delta": "!"})) + .await + .unwrap(); + t.append( + "tool-call", + Some(sid.clone()), + rid_tool.clone(), + json!({"name": "fs.read", "args": {"path": "/tmp/x"}}), + ) + .await + .unwrap(); + t.append( + "tool-result", + Some(sid.clone()), + rid_tool, + json!({"ok": true, "bytes": 42}), + ) + .await + .unwrap(); + t.append("finish", Some(sid.clone()), rid_prompt, json!({"reason": "end"})) + .await + .unwrap(); + + t.range(t.author_hid(), 0).unwrap() + }; + + assert_eq!(originals.len(), 8); + + let second = TheHum::open(tmp.path(), key, Config::default()).unwrap(); + let mut seen = Vec::new(); + second.replay(|e| seen.push(e.clone())).unwrap(); + + assert_eq!(seen.len(), originals.len(), "replay yields every appended event"); + for (a, b) in originals.iter().zip(seen.iter()) { + assert_eq!(a.seq, b.seq); + assert_eq!(a.chi, b.chi); + assert_eq!(a.body, b.body, "body must round-trip byte-for-byte"); + assert_eq!(a.sid, b.sid); + assert_eq!(a.ts_ms, b.ts_ms, "ts_ms must flow through replay unchanged"); + assert_eq!(a.author, b.author); + assert_eq!(a.prev_hash, b.prev_hash); + assert_eq!(a.sig, b.sig); + } +} + +#[tokio::test] +async fn cross_session_replay_isolates_correctly() { + let tmp = TempDir::new().unwrap(); + let key = fresh_key(); + let t = TheHum::open(tmp.path(), key, Config::default()).unwrap(); + + let sid_a = HumId::mint(); + let sid_b = HumId::mint(); + + let order_a = vec!["hello", "prompt", "chunk", "chunk", "finish"]; + let order_b = vec!["hello", "prompt", "tool-call", "tool-result", "finish"]; + + // Interleave: a, b, a, b, ... + for i in 0..order_a.len().max(order_b.len()) { + if i < order_a.len() { + t.append(order_a[i], Some(sid_a.clone()), HumId::mint(), json!({"i": i, "side": "a"})) + .await + .unwrap(); + } + if i < order_b.len() { + t.append(order_b[i], Some(sid_b.clone()), HumId::mint(), json!({"i": i, "side": "b"})) + .await + .unwrap(); + } + } + + let mut buckets: HashMap> = HashMap::new(); + t.replay(|e| { + if let Some(s) = e.sid.clone() { + buckets.entry(s).or_default().push((e.seq, e.chi.clone())); + } + }) + .unwrap(); + + let a_bucket = buckets.get(&sid_a).expect("sid_a present"); + let b_bucket = buckets.get(&sid_b).expect("sid_b present"); + + let a_chis: Vec<&str> = a_bucket.iter().map(|(_, c)| c.as_str()).collect(); + let b_chis: Vec<&str> = b_bucket.iter().map(|(_, c)| c.as_str()).collect(); + assert_eq!(a_chis, order_a, "sid_a history intact in order"); + assert_eq!(b_chis, order_b, "sid_b history intact in order"); + + // Seqs must be strictly increasing within each bucket. + for bucket in [a_bucket, b_bucket] { + for win in bucket.windows(2) { + assert!(win[0].0 < win[1].0, "seqs monotonic within sid bucket"); + } + } +} + +#[tokio::test] +async fn tampered_line_fails_verification() { + let tmp = TempDir::new().unwrap(); + let key = fresh_key(); + let pubkey = key.verifying_key(); + let t = TheHum::open(tmp.path(), key, Config::default()).unwrap(); + let rid = HumId::mint(); + for i in 0..3u32 { + t.append("e", None, rid.clone(), json!({"i": i})).await.unwrap(); + } + + // Find the daily ndjson, mutate the middle line's body but keep JSON valid. + let entries: Vec<_> = std::fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ndjson")) + .collect(); + assert_eq!(entries.len(), 1, "exactly one daily file"); + let path = &entries[0]; + let content = std::fs::read_to_string(path).unwrap(); + let mut lines: Vec = content.lines().map(String::from).collect(); + assert_eq!(lines.len(), 3, "three lines on disk"); + + let mut middle: Value = serde_json::from_str(&lines[1]).unwrap(); + middle["body"]["i"] = json!(999); + lines[1] = serde_json::to_string(&middle).unwrap(); + std::fs::write(path, lines.join("\n") + "\n").unwrap(); + + // Re-read events from disk and verify. + let events = t.range(t.author_hid(), 0).unwrap(); + assert_eq!(events.len(), 3); + let result = verify_chain(&events, &pubkey); + assert!( + result.is_err(), + "verify_chain must reject tampered event, but returned Ok" + ); +} + +#[tokio::test] +async fn snapshot_root_is_stable_across_runs() { + let tmp1 = TempDir::new().unwrap(); + let tmp2 = TempDir::new().unwrap(); + + let mut leaves: BTreeMap = BTreeMap::new(); + leaves.insert("bee/aaa".into(), json!({"hid": "aaa", "online": true, "rps": 12})); + leaves.insert("bee/bbb".into(), json!({"hid": "bbb", "online": false})); + leaves.insert("sid/xyz".into(), json!({"bees": ["aaa", "bbb"], "state": "idle"})); + + let t1 = TheHum::open(tmp1.path(), fresh_key(), Config::default()).unwrap(); + let t2 = TheHum::open(tmp2.path(), fresh_key(), Config::default()).unwrap(); + + // Append the same logical sequence into both — they'll have different + // signers/timestamps/prev_hashes, but the state Merkle root depends + // only on the leaves we pass to snapshot(). + let rid = HumId::mint(); + for chi in ["hello", "prompt", "chunk", "finish"] { + t1.append(chi, None, rid.clone(), json!({"chi": chi})).await.unwrap(); + t2.append(chi, None, rid.clone(), json!({"chi": chi})).await.unwrap(); + } + + let root1 = t1.snapshot(leaves.clone()).await.unwrap(); + let root2 = t2.snapshot(leaves.clone()).await.unwrap(); + + assert_eq!(root1, root2, "snapshot root depends on leaves, not the log"); + assert_ne!(root1, [0u8; 32], "non-empty leaves yield non-zero root"); +} + +#[test] +fn retention_rolling_only_drops_old_files() { + let tmp = TempDir::new().unwrap(); + let old_path = tmp.path().join("2020-01-01.ndjson"); + std::fs::write(&old_path, "").unwrap(); + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + let today_path = tmp.path().join(format!("{today}.ndjson")); + std::fs::write(&today_path, "").unwrap(); + + let cfg = Config { + mode: RetentionMode::Rolling, + days: 1, + ..Config::default() + }; + let t = TheHum::open(tmp.path(), fresh_key(), cfg).unwrap(); + let report = t.enforce_retention().unwrap(); + + assert_eq!(report.removed_files, 1); + assert_eq!(report.kept_files, 1); + assert!(!old_path.exists(), "ancient file removed"); + assert!(today_path.exists(), "today file kept"); +} + +#[test] +fn light_mode_keeps_only_latest_daily_file() { + let tmp = TempDir::new().unwrap(); + let days = ["2020-01-01", "2024-06-15", "2026-05-30"]; + for d in &days { + std::fs::write(tmp.path().join(format!("{d}.ndjson")), "").unwrap(); + } + + let cfg = Config { + mode: RetentionMode::Light, + ..Config::default() + }; + let t = TheHum::open(tmp.path(), fresh_key(), cfg).unwrap(); + let report = t.enforce_retention().unwrap(); + + assert_eq!(report.removed_files, 2); + assert_eq!(report.kept_files, 1); + + let remaining: Vec<_> = std::fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ndjson")) + .collect(); + assert_eq!(remaining.len(), 1, "exactly one ndjson survives"); + let stem = remaining[0].file_stem().unwrap().to_str().unwrap(); + assert_eq!(stem, "2026-05-30", "newest daily file is the survivor"); +} + +#[tokio::test] +async fn tail_broadcast_skips_to_lagging_receiver() { + let tmp = TempDir::new().unwrap(); + let t = TheHum::open(tmp.path(), fresh_key(), Config::default()).unwrap(); + let mut rx = t.tail(); + let rid = HumId::mint(); + + // Channel cap is 1024 — append more than that without draining. + for i in 0..1500u32 { + t.append("e", None, rid.clone(), json!({"i": i})).await.unwrap(); + } + + // First recv should surface a Lagged error; we just need to confirm + // it doesn't panic and we can keep consuming. + let mut saw_lagged = false; + let mut received = 0usize; + loop { + match rx.try_recv() { + Ok(_) => received += 1, + Err(TryRecvError::Lagged(_)) => saw_lagged = true, + Err(TryRecvError::Empty) | Err(TryRecvError::Closed) => break, + } + } + assert!(saw_lagged, "lagging receiver must see a Lagged error"); + assert!(received > 0, "channel still yields events after lag"); +} diff --git a/thrum-clients/go/thrum/chi.go b/thrum-clients/go/thrum/chi.go index fcf938d5..2a9a50b5 100644 --- a/thrum-clients/go/thrum/chi.go +++ b/thrum-clients/go/thrum/chi.go @@ -80,6 +80,8 @@ const ( ChiKadFindNode Chi = "kad-find-node" // Kademlia DHT FIND_NODE response — `{ query_id, from: , closest: [, ...] }`. Matched to the originating `kad-find-node` by `query_id`. The lookup driver inserts every advertised HumdAddr into its routing table and re-queries the α closest unqueried peers until no closer node is returned. ChiKadFindNodeResp Chi = "kad-find-node-resp" + // thehum chi-log replay request — `{ author: , from: }`. Host humd answers with one `chi:"backfill-event"` tone per event in `[from, ..)` for the named author. + ChiBackfill Chi = "backfill" ) // AllChi is the set of every known chi value for membership checks. @@ -117,6 +119,7 @@ var AllChi = map[Chi]struct{}{ ChiGossipPublish: {}, ChiKadFindNode: {}, ChiKadFindNodeResp: {}, + ChiBackfill: {}, } // IsValidChi returns true if value is a known chi. diff --git a/thrum-clients/python/thrum/chi.py b/thrum-clients/python/thrum/chi.py index f3fedd05..e457f978 100644 --- a/thrum-clients/python/thrum/chi.py +++ b/thrum-clients/python/thrum/chi.py @@ -77,6 +77,8 @@ class Chi: KAD_FIND_NODE: str = "kad-find-node" # Kademlia DHT FIND_NODE response — `{ query_id, from: , closest: [, ...] }`. Matched to the originating `kad-find-node` by `query_id`. The lookup driver inserts every advertised HumdAddr into its routing table and re-queries the α closest unqueried peers until no closer node is returned. KAD_FIND_NODE_RESP: str = "kad-find-node-resp" + # thehum chi-log replay request — `{ author: , from: }`. Host humd answers with one `chi:"backfill-event"` tone per event in `[from, ..)` for the named author. + BACKFILL: str = "backfill" ALL_CHI: frozenset[str] = frozenset({ "hello", @@ -112,6 +114,7 @@ class Chi: "gossip-publish", "kad-find-node", "kad-find-node-resp", + "backfill", }) def is_valid_chi(value: str) -> bool: diff --git a/thrum-clients/ts/chi.ts b/thrum-clients/ts/chi.ts index 657bc26a..e7f18a84 100644 --- a/thrum-clients/ts/chi.ts +++ b/thrum-clients/ts/chi.ts @@ -75,6 +75,8 @@ export const Chi = { kadFindNode: "kad-find-node", /** Kademlia DHT FIND_NODE response — `{ query_id, from: , closest: [, ...] }`. Matched to the originating `kad-find-node` by `query_id`. The lookup driver inserts every advertised HumdAddr into its routing table and re-queries the α closest unqueried peers until no closer node is returned. */ kadFindNodeResp: "kad-find-node-resp", + /** thehum chi-log replay request — `{ author: , from: }`. Host humd answers with one `chi:"backfill-event"` tone per event in `[from, ..)` for the named author. */ + backfill: "backfill", } as const; export type ChiKind = typeof Chi[keyof typeof Chi]; diff --git a/thrum-core/src/chi.rs b/thrum-core/src/chi.rs index 9b27dd92..225b0203 100644 --- a/thrum-core/src/chi.rs +++ b/thrum-core/src/chi.rs @@ -121,6 +121,10 @@ pub enum Chi { /// table and re-queries the α closest unqueried peers until no /// closer node is returned. KadFindNodeResp, + /// thehum chi-log replay request — `{ author: , from: }`. + /// Host humd answers with one `chi:"backfill-event"` tone per event + /// in `[from, ..)` for the named author. + Backfill, } /// `pulse.kind` — its own enum within `chi:"pulse"` tones. From 17194870fafbe8c4d1eb3b2becf166452a5c63b2 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sun, 31 May 2026 09:53:38 +0000 Subject: [PATCH 15/18] hum-paths: absorb every on-disk name + foreign-format anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote every path construction + every literal basename across the workspace into hum-paths. Single source of truth for everything hum reads or writes; nothing constructs a hum filename outside this crate. New constants (canonical basenames): THRUM_SOCK_BASENAME, HTTP_SOCK_BASENAME, PENNY_BASENAME, HUMD_KEY_BASENAME, BEES_SNAPSHOT_BASENAME, RUNTIME_INFO_BASENAME, HUM_JSON_BASENAME, PEERS_JSON_BASENAME, ORCHFILE_BASENAME, HIVES_SUBDIR, RECIPES_SUBDIR, HIVE_INSTALL_SCRIPT New helpers (composed paths): home(), expand_tilde(p) — single tilde-expander for user config local_dir(), local_bin_dir() — $HOME/.local + .local/bin hum_bin(name) — installed-binary location for a hum binary fnm_node_bin() — fnm-managed node fallback claude_data_dir(), claude_session_dir(cwd_hash) — claude CLI layout orch_d_dir(), orchfile() — orchd integration files foreign_hive_cache(org, repo, branch) — github-source clone cache svc_script() — scripts/svc.sh shipped with the source clone Migration: - config::expand_tilde delegates to hum_paths::expand_tilde - hives/{claude-cli,claude-repl} propagate HOME via hum_paths::home() - claude-cli/graft uses claude_session_dir(cwd_hash) - hum CLI's home_local() / hum_orchfile() helpers deleted; callers use hum_paths::{local_dir, local_bin_dir, hum_bin, orchfile, orch_d_dir, foreign_hive_cache, svc_script, HIVES_SUBDIR, RECIPES_SUBDIR, HIVE_INSTALL_SCRIPT, ORCHFILE_BASENAME} - sim, penny, humd/peers tests reach for hum_paths::*_BASENAME and hum_paths::peers_json() instead of literal filenames - humd/peers test uses hum_paths::config_dir() after XDG_CONFIG_HOME override (was reconstructing 'hum' subdir manually) The only HOME / XDG_* reads left in the workspace: - hum-paths itself (the source of truth) - doctor diagnostics (reports raw env values to the user) - test isolation (set_var XDG_* on tmp dirs) All routine code reaches for hum_paths instead. 275 tests pass. --- Cargo.lock | 2 + config/src/lib.rs | 14 +---- hives/claude-cli/src/graft/mod.rs | 7 +-- hives/claude-cli/src/lib.rs | 4 +- hives/claude-repl/src/lib.rs | 2 +- hum-paths/src/lib.rs | 92 +++++++++++++++++++++++++++---- hum/src/main.rs | 38 ++++++------- humd/src/peers.rs | 5 +- penny/Cargo.toml | 1 + penny/src/lib.rs | 4 +- sim/Cargo.toml | 1 + sim/src/lib.rs | 12 ++-- 12 files changed, 114 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfc31151..e688fb33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3622,6 +3622,7 @@ dependencies = [ name = "penny" version = "0.31.18" dependencies = [ + "hum-paths", "parking_lot", "serde", "serde_json", @@ -4785,6 +4786,7 @@ dependencies = [ "anyhow", "config", "ensemble", + "hum-paths", "humd", "ids", "mcp", diff --git a/config/src/lib.rs b/config/src/lib.rs index e5ac99c2..f33d94c5 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -154,20 +154,8 @@ pub fn config_path() -> PathBuf { hum_paths::hum_json() } -/// Expand `~` against `$HOME`. Leaves absolute / non-tilde paths alone. fn expand_tilde(p: &Path) -> PathBuf { - let s = p.to_string_lossy(); - if let Some(rest) = s.strip_prefix("~/") { - if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home).join(rest); - } - } - if s == "~" { - if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home); - } - } - p.to_path_buf() + hum_paths::expand_tilde(p) } fn canonical_or_self(p: &Path) -> PathBuf { diff --git a/hives/claude-cli/src/graft/mod.rs b/hives/claude-cli/src/graft/mod.rs index 2ef91feb..464b72d4 100644 --- a/hives/claude-cli/src/graft/mod.rs +++ b/hives/claude-cli/src/graft/mod.rs @@ -36,13 +36,8 @@ fn cwd_hash(cwd: &Path) -> String { out } -fn claude_base() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_default(); - PathBuf::from(home).join(".claude") -} - pub fn session_dir(cwd: &Path) -> PathBuf { - claude_base().join("projects").join(cwd_hash(cwd)) + hum_paths::claude_session_dir(&cwd_hash(cwd)) } pub fn session_path(cwd: &Path, session_id: &str) -> PathBuf { diff --git a/hives/claude-cli/src/lib.rs b/hives/claude-cli/src/lib.rs index 0c7a2030..90716e4a 100644 --- a/hives/claude-cli/src/lib.rs +++ b/hives/claude-cli/src/lib.rs @@ -99,9 +99,7 @@ pub fn build_env(spec: &Egg) -> Vec<(String, String)> { if let Ok(path) = std::env::var("PATH") { env.push(("PATH".into(), path)); } - if let Ok(home) = std::env::var("HOME") { - env.push(("HOME".into(), home)); - } + env.push(("HOME".into(), hum_paths::home().to_string_lossy().into_owned())); for (k, v) in &spec.env { env.push((k.clone(), v.clone())); } diff --git a/hives/claude-repl/src/lib.rs b/hives/claude-repl/src/lib.rs index 3189bf37..dd1da9de 100644 --- a/hives/claude-repl/src/lib.rs +++ b/hives/claude-repl/src/lib.rs @@ -91,7 +91,7 @@ impl WorkerBee for ClaudeReplWorker { let mut cmd = CommandBuilder::new(&cli); cmd.cwd(&spec.cwd); if let Ok(path) = std::env::var("PATH") { cmd.env("PATH", path); } - if let Ok(home) = std::env::var("HOME") { cmd.env("HOME", home); } + cmd.env("HOME", hum_paths::home().to_string_lossy().into_owned()); cmd.env("TERM", "xterm-256color"); cmd.env("CLAUDE_CODE_DISABLE_CLAUDE_MDS", "1"); cmd.env("CLAUDE_CODE_DISABLE_AUTO_MEMORY", "1"); diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index 980d1793..e8a396a3 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -58,6 +58,33 @@ pub fn runtime_dir() -> PathBuf { xdg("XDG_RUNTIME_DIR").join("hum") } +// ── Canonical file basenames ──────────────────────────────────────────────── +// Single source of truth for every filename hum reads or writes. Tests and +// any code building paths under a non-default root must compose these +// against their own directory, NEVER hardcode the strings. + +pub const THRUM_SOCK_BASENAME: &str = "thrum.sock"; +pub const HTTP_SOCK_BASENAME: &str = "hum.sock.http"; +pub const PENNY_BASENAME: &str = "penny.json"; +pub const HUMD_KEY_BASENAME: &str = "humd.key"; +pub const BEES_SNAPSHOT_BASENAME: &str = "bees.json"; +pub const RUNTIME_INFO_BASENAME: &str = "runtime.json"; +pub const HUM_JSON_BASENAME: &str = "hum.json"; +pub const PEERS_JSON_BASENAME: &str = "peers.json"; +pub const ORCHFILE_BASENAME: &str = "Orchfile"; +/// Subdirectory of a hum source tree that holds hive crates. +pub const HIVES_SUBDIR: &str = "hives"; +/// Subdirectory of a hum source tree that holds recipes (installable bundles). +pub const RECIPES_SUBDIR: &str = "recipes"; +/// Per-hive install script name (found inside each hive crate root). +pub const HIVE_INSTALL_SCRIPT: &str = "install"; + +/// Claude CLI's per-cwd transcript dir, given a cwd hash. +/// Layout: `~/.claude/projects//`. +pub fn claude_session_dir(cwd_hash: &str) -> PathBuf { + claude_data_dir().join("projects").join(cwd_hash) +} + // ── Named files ────────────────────────────────────────────────────────────── /// Default thrum socket path. The path humd would BIND if nothing @@ -67,7 +94,7 @@ pub fn runtime_dir() -> PathBuf { pub fn thrum_sock() -> PathBuf { if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { return PathBuf::from(p); } if let Some(p) = std::env::var_os("HUM_SOCKET") { return PathBuf::from(p); } - state_dir().join("thrum.sock") + state_dir().join(THRUM_SOCK_BASENAME) } /// What clients (bees, CLI) should connect to. Honors humd's @@ -76,17 +103,17 @@ pub fn thrum_sock_resolved() -> PathBuf { if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { return PathBuf::from(p); } if let Some(p) = std::env::var_os("HUM_SOCKET") { return PathBuf::from(p); } if let Some(rt) = RuntimeInfo::read() { return rt.socket; } - state_dir().join("thrum.sock") + state_dir().join(THRUM_SOCK_BASENAME) } /// humd HTTP control socket. -pub fn http_sock() -> PathBuf { runtime_dir().join("hum.sock.http") } +pub fn http_sock() -> PathBuf { runtime_dir().join(HTTP_SOCK_BASENAME) } /// Penny lifetime counters. -pub fn penny() -> PathBuf { runtime_dir().join("penny.json") } +pub fn penny() -> PathBuf { runtime_dir().join(PENNY_BASENAME) } /// humd ed25519 identity seed. -pub fn humd_key() -> PathBuf { state_dir().join("humd.key") } +pub fn humd_key() -> PathBuf { state_dir().join(HUMD_KEY_BASENAME) } /// Directory holding per-bee ed25519 identity seeds. pub fn bees_dir() -> PathBuf { state_dir().join("bees") } @@ -97,10 +124,10 @@ pub fn bee_key(kind: &str) -> PathBuf { } /// Live bee manifest snapshot (written by daemon on every register/disconnect). -pub fn bees_snapshot() -> PathBuf { state_dir().join("bees.json") } +pub fn bees_snapshot() -> PathBuf { state_dir().join(BEES_SNAPSHOT_BASENAME) } /// Rendezvous file: running daemon publishes its socket path, pid, and version here. -pub fn runtime_info() -> PathBuf { state_dir().join("runtime.json") } +pub fn runtime_info() -> PathBuf { state_dir().join(RUNTIME_INFO_BASENAME) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeInfo { @@ -137,10 +164,10 @@ impl RuntimeInfo { } /// `hum.json` (daemon policy). -pub fn hum_json() -> PathBuf { config_dir().join("hum.json") } +pub fn hum_json() -> PathBuf { config_dir().join(HUM_JSON_BASENAME) } /// `peers.json` (ensemble peer list). -pub fn peers_json() -> PathBuf { config_dir().join("peers.json") } +pub fn peers_json() -> PathBuf { config_dir().join(PEERS_JSON_BASENAME) } /// Drift rings directory (`drift/YYYY-MM-DD.ndjson`). pub fn drift_dir() -> PathBuf { state_dir().join("drift") } @@ -151,11 +178,42 @@ pub fn thehum_dir() -> PathBuf { state_dir().join("thehum") } /// Cloned hum source tree (recipes + hive installers). pub fn src_dir() -> PathBuf { data_dir().join("src") } +/// Helper script ship with hum's source clone — used by the CLI when +/// running cross-platform service operations (systemctl / launchctl wrap). +pub fn svc_script() -> PathBuf { src_dir().join("scripts/svc.sh") } + +/// `$HOME/.local` — the base for HOME-anchored installs that aren't +/// resolved through XDG (cargo install --root, .local/bin, etc). +pub fn local_dir() -> PathBuf { home().join(".local") } + +/// `$HOME/.local/bin` — where installed hum binaries live. +pub fn local_bin_dir() -> PathBuf { local_dir().join("bin") } + /// Installed humd binary location. -pub fn humd_bin() -> PathBuf { - home().join(".local/bin/humd") +pub fn humd_bin() -> PathBuf { local_bin_dir().join("humd") } + +/// fnm-managed node binary (when fnm is the user's node manager). +/// Probed as a fallback after PATH + /usr/local/bin/node. +pub fn fnm_node_bin() -> PathBuf { local_dir().join("share/fnm/aliases/default/bin/node") } + +/// `~/.claude` — Claude CLI's data dir. Read by the claude-cli graft for +/// transcript replay; we don't write here. +pub fn claude_data_dir() -> PathBuf { home().join(".claude") } + +/// Installed-binary location for a given hum binary name. +pub fn hum_bin(name: &str) -> PathBuf { local_bin_dir().join(name) } + +/// Cache dir for a foreign hive clone (org/repo/branch). +pub fn foreign_hive_cache(org: &str, repo: &str, branch: &str) -> PathBuf { + cache_dir().join("hives").join(format!("{org}-{repo}-{branch}")) } +/// orch.d directory — one .orch file per registered hive. +pub fn orch_d_dir() -> PathBuf { config_dir().join("orch.d") } + +/// Aggregate Orchfile — orchd reads this; we rebuild it from orch.d. +pub fn orchfile() -> PathBuf { config_dir().join(ORCHFILE_BASENAME) } + /// Per-bee config file for a given hive kind (e.g. `ollama-server.json`). pub fn bee_config(kind: &str) -> PathBuf { config_dir().join("bees").join(format!("{kind}.json")) @@ -190,8 +248,18 @@ fn xdg(var: &str) -> PathBuf { PathBuf::from(std::env::var_os(var).expect("init() set the var")) } -fn home() -> PathBuf { +pub fn home() -> PathBuf { std::env::var_os("HOME") .map(PathBuf::from) .expect("HOME must be set") } + +/// Expand a leading `~/` or bare `~` against `$HOME`. Leaves absolute or +/// non-tilde paths alone. Single source of truth for user-config path +/// expansion across the workspace. +pub fn expand_tilde(p: &std::path::Path) -> PathBuf { + let s = p.to_string_lossy(); + if let Some(rest) = s.strip_prefix("~/") { return home().join(rest); } + if s == "~" { return home(); } + p.to_path_buf() +} diff --git a/hum/src/main.rs b/hum/src/main.rs index 1e115d87..1bc72341 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -257,7 +257,7 @@ fn svc_helper() -> Option { std::env::current_exe().ok() .and_then(|p| p.parent().map(|p| p.to_path_buf())) .map(|p| p.join("../../scripts/svc.sh")), - Some(hum_paths::src_dir().join("scripts/svc.sh")), + Some(hum_paths::svc_script()), Some(PathBuf::from("./scripts/svc.sh")), ]; candidates.into_iter().flatten().find(|p| p.exists()) @@ -529,11 +529,11 @@ fn hive_list() -> Result<()> { use std::collections::BTreeMap; // kind -> (has installer, configured model, running) let root = repo_root_or_install_dir(); - let hives_dir = root.join("hives"); + let hives_dir = root.join(hum_paths::HIVES_SUBDIR); let mut kinds: BTreeMap, bool)> = BTreeMap::new(); if let Ok(entries) = std::fs::read_dir(&hives_dir) { for e in entries.flatten() { - if e.path().is_dir() && e.path().join("install").exists() { + if e.path().is_dir() && e.path().join(hum_paths::HIVE_INSTALL_SCRIPT).exists() { kinds.entry(e.file_name().to_string_lossy().to_string()).or_default().0 = true; } } @@ -592,7 +592,7 @@ fn hive_list() -> Result<()> { /// foreign repo is shallow-cloned to a cache. fn hive_install(reference: &str) -> Result<()> { let dir = resolve_hive_dir(reference)?; - let orchfile = dir.join("Orchfile"); + let orchfile = dir.join(hum_paths::ORCHFILE_BASENAME); if !orchfile.exists() { anyhow::bail!("no Orchfile at {}", orchfile.display()); } @@ -601,7 +601,7 @@ fn hive_install(reference: &str) -> Result<()> { build_hive(&dir, &kind)?; - let orch_d = hum_paths::config_dir().join("orch.d"); + let orch_d = hum_paths::orch_d_dir(); std::fs::create_dir_all(&orch_d)?; let dest = orch_d.join(format!("{kind}.orch")); std::fs::copy(&orchfile, &dest)?; @@ -634,7 +634,7 @@ fn build_cargo(dir: &Path, kind: &str) -> Result<()> { println!("building {kind} (cargo install --path {}) ...", dir.display()); let s = Command::new("cargo") .args(["install", "--quiet", "--locked", "--path"]).arg(dir) - .args(["--root"]).arg(home_local()) + .args(["--root"]).arg(hum_paths::local_dir()) .arg("--force") .status()?; if !s.success() { anyhow::bail!("cargo install failed for {}", dir.display()); } @@ -659,9 +659,9 @@ fn build_node(dir: &Path, kind: &str) -> Result<()> { anyhow::bail!("build did not produce {}", dist.display()); } let node = which_first(&["node", "/usr/local/bin/node"]) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/fnm/aliases/default/bin/node")).filter(|p| p.exists())) + .or_else(|| Some(hum_paths::fnm_node_bin()).filter(|p| p.exists())) .ok_or_else(|| anyhow::anyhow!("node not in PATH; install Node 22+"))?; - let bin = home_local().join("bin").join(kind); + let bin = hum_paths::hum_bin(kind); std::fs::create_dir_all(bin.parent().unwrap())?; let wrapper = format!( "#!/usr/bin/env bash\nexec {} {} \"$@\"\n", @@ -678,7 +678,7 @@ fn build_node(dir: &Path, kind: &str) -> Result<()> { fn build_go(dir: &Path, kind: &str) -> Result<()> { if !which("go") { anyhow::bail!("go not in PATH; install Go"); } - let bin = home_local().join("bin").join(kind); + let bin = hum_paths::hum_bin(kind); std::fs::create_dir_all(bin.parent().unwrap())?; println!("building {kind} (go build) in {}", dir.display()); let s = Command::new("go").args(["build", "-o"]).arg(&bin).arg(".").current_dir(dir).status()?; @@ -722,15 +722,10 @@ fn rewrite_hum_orchfile(orch_d: &Path) -> Result<()> { if !combined.ends_with('\n') { combined.push('\n'); } combined.push('\n'); } - std::fs::write(hum_orchfile(), combined)?; + std::fs::write(hum_paths::orchfile(), combined)?; Ok(()) } -fn home_local() -> PathBuf { - std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(".")) - .join(".local") -} - fn resolve_hive_dir(reference: &str) -> Result { if let Some(rest) = reference.strip_prefix("https://github.com/") { let parts: Vec<&str> = rest.splitn(5, '/').collect(); @@ -739,7 +734,7 @@ fn resolve_hive_dir(reference: &str) -> Result { if org == "adiled" && repo == "hum" { return Ok(repo_root_or_install_dir().join(sub)); } - let cache = hum_paths::cache_dir().join("hives").join(format!("{org}-{repo}-{branch}")); + let cache = hum_paths::foreign_hive_cache(org, repo, branch); if !cache.exists() { std::fs::create_dir_all(cache.parent().unwrap()).ok(); let url = format!("https://github.com/{org}/{repo}"); @@ -755,7 +750,7 @@ fn resolve_hive_dir(reference: &str) -> Result { } let p = PathBuf::from(reference); if p.is_dir() { return Ok(p); } - let bundled = repo_root_or_install_dir().join("hives").join(reference); + let bundled = repo_root_or_install_dir().join(hum_paths::HIVES_SUBDIR).join(reference); if bundled.exists() { return Ok(bundled); } anyhow::bail!("can't resolve hive '{reference}' (not a bundled name, path, or github source URL)"); } @@ -899,7 +894,7 @@ fn penny() -> Result<()> { fn recipes(name: Option) -> Result<()> { let root = repo_root_or_install_dir(); - let recipes_dir = root.join("recipes"); + let recipes_dir = root.join(hum_paths::RECIPES_SUBDIR); if !recipes_dir.exists() { println!("no recipes/ dir at {}", recipes_dir.display()); return Ok(()); @@ -917,7 +912,7 @@ fn recipes(name: Option) -> Result<()> { println!("Run one with: hum recipes "); } Some(n) => { - let install = recipes_dir.join(&n).join("install"); + let install = recipes_dir.join(&n).join(hum_paths::HIVE_INSTALL_SCRIPT); if !install.exists() { anyhow::bail!("recipes/{n}/install not found"); } @@ -949,11 +944,10 @@ fn repo_root_or_install_dir() -> PathBuf { // ── orchd shell-outs (bee lifecycle) ───────────────────────────────────── -fn hum_orchfile() -> PathBuf { hum_paths::config_dir().join("Orchfile") } fn orchd_cmd() -> Command { let mut c = Command::new("orchd"); - c.arg("--orchfile").arg(hum_orchfile()) + c.arg("--orchfile").arg(hum_paths::orchfile()) .arg("--user") .arg("--namespace").arg("hum"); c @@ -961,7 +955,7 @@ fn orchd_cmd() -> Command { /// Service names declared in hum's Orchfile. fn orch_catalog() -> Vec { - let path = hum_orchfile(); + let path = hum_paths::orchfile(); let Ok(raw) = std::fs::read_to_string(&path) else { return Vec::new(); }; raw.lines() .filter_map(|l| l.trim().strip_prefix("SERVICE ").map(|s| s.trim().to_string())) diff --git a/humd/src/peers.rs b/humd/src/peers.rs index 234e25b7..8c5edd8f 100644 --- a/humd/src/peers.rs +++ b/humd/src/peers.rs @@ -114,8 +114,7 @@ mod tests { fn load_parses_fixture_and_skips_bad_rows() { let tmp = TempDir::new().unwrap(); std::env::set_var("XDG_CONFIG_HOME", tmp.path()); - let dir = tmp.path().join("hum"); - std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(hum_paths::config_dir()).unwrap(); let good_a = "a".repeat(64); let good_b = "b".repeat(64); @@ -129,7 +128,7 @@ mod tests { ] }}"# ); - std::fs::write(dir.join("peers.json"), body).unwrap(); + std::fs::write(hum_paths::peers_json(), body).unwrap(); let loaded = load(); assert_eq!(loaded.len(), 2, "bad row dropped"); diff --git a/penny/Cargo.toml b/penny/Cargo.toml index 7eeb783c..ed36432c 100644 --- a/penny/Cargo.toml +++ b/penny/Cargo.toml @@ -14,3 +14,4 @@ tracing = { workspace = true } [dev-dependencies] tempfile = "3" +hum-paths = { path = "../hum-paths" } diff --git a/penny/src/lib.rs b/penny/src/lib.rs index 6fd6649e..ad480b22 100644 --- a/penny/src/lib.rs +++ b/penny/src/lib.rs @@ -168,7 +168,7 @@ mod tests { #[test] fn save_then_load_roundtrip() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("nested/penny.json"); + let path = dir.path().join(hum_paths::PENNY_BASENAME); let p = Penny::new(); p.incr_by("curateBytesSaved", 99_999); p.incr("taskExecutions"); @@ -191,7 +191,7 @@ mod tests { fn load_skips_non_numeric_fields() { // Mirrors the TS shape that included a `started: ` timestamp alongside counters. let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("penny.json"); + let path = dir.path().join(hum_paths::PENNY_BASENAME); std::fs::write( &path, br#"{"started":1700000000000,"blooms":7,"label":"ignored","fractional":3.7}"#, diff --git a/sim/Cargo.toml b/sim/Cargo.toml index 8032b0ed..94f41913 100644 --- a/sim/Cargo.toml +++ b/sim/Cargo.toml @@ -13,6 +13,7 @@ thrumd = { path = "../thrumd" } nest = { path = "../nest" } ids = { path = "../ids" } config = { path = "../config" } +hum-paths = { path = "../hum-paths" } tokio = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } diff --git a/sim/src/lib.rs b/sim/src/lib.rs index edb430a6..0318923f 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -137,7 +137,7 @@ impl Sim { // is supplied, but DaemonConfig wants something to hold. let tmp = std::env::temp_dir().join(format!("sim-humd-{}", id.short())); let _ = std::fs::create_dir_all(&tmp); - let penny_path = tmp.join("penny.json"); + let penny_path = tmp.join(hum_paths::PENNY_BASENAME); // Drain any pre-spawn capacity hint and reuse it for both the // daemon's overflow policy AND the SimHumd's published atomic so @@ -155,8 +155,8 @@ impl Sim { let waneman = Arc::new(WaneTracker::new()); let cfg = humd::DaemonConfig { - thrum_path: tmp.join("thrum.sock"), - http_path: tmp.join("http.sock"), + thrum_path: tmp.join(hum_paths::THRUM_SOCK_BASENAME), + http_path: tmp.join(hum_paths::HTTP_SOCK_BASENAME), mcp_addr: ([127, 0, 0, 1], 0).into(), penny_path, hum_cfg: config::HumConfig::default(), @@ -276,7 +276,7 @@ impl Sim { let tmp = std::env::temp_dir().join(format!("sim-humd-{}", id.short())); let _ = std::fs::create_dir_all(&tmp); - let penny_path = tmp.join("penny.json"); + let penny_path = tmp.join(hum_paths::PENNY_BASENAME); let initial_capacity = self .pending_capacities @@ -291,8 +291,8 @@ impl Sim { let waneman = Arc::new(WaneTracker::new()); let cfg = humd::DaemonConfig { - thrum_path: tmp.join("thrum.sock"), - http_path: tmp.join("http.sock"), + thrum_path: tmp.join(hum_paths::THRUM_SOCK_BASENAME), + http_path: tmp.join(hum_paths::HTTP_SOCK_BASENAME), mcp_addr: ([127, 0, 0, 1], 0).into(), penny_path, hum_cfg: config::HumConfig::default(), From 7c5c9b5a6844bd6f28724afe26bd4a86a10300b1 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sun, 31 May 2026 10:02:55 +0000 Subject: [PATCH 16/18] =?UTF-8?q?hum=20CLI:=20finish=20svc.sh=20death=20?= =?UTF-8?q?=E2=80=94=20orchd=20+=20humctl=20own=20service=20ops=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b25ddc3 killed scripts/svc.sh but the CLI kept calling its functions through dead bash shell-outs (svc_helper, svc_active, svc_last_exit, svc_start/stop/restart, svc_list, svc_uninstall, svc_status). Removing all of it. Deletions: - svc_helper() — scripts/svc.sh discovery - svc_active(), svc_last_exit() — bash exit-code probes - bee_list(svc) — bash svc_list scraper - resolve_units() — unit name resolver only used by the bash path - hum_paths::svc_script() — no callers after this commit - All bash shell-outs to svc_start/stop/restart/uninstall/status Rewrites: - bee_list_full now takes a Vec from orch_catalog() (installed hive kinds), not svc-discovered unit names. State printed as 'orchd-managed' / 'unmanaged' / 'installed, not handshaked' based on presence in humd's bees.json + orch catalog. - bee() routes every verb through orch_route_verb. No bash fallback. - hive_list() marks running kinds from orch_catalog() (no svc_list). - uninstall() calls 'humctl stop' instead of bash svc_uninstall. - status() drops the trailing 'svc_status hum' bash call (humctl status is the canonical surface now). 275 tests pass. --- hum-paths/src/lib.rs | 4 - hum/src/main.rs | 175 +++++-------------------------------------- 2 files changed, 18 insertions(+), 161 deletions(-) diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index e8a396a3..6f5b7799 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -178,10 +178,6 @@ pub fn thehum_dir() -> PathBuf { state_dir().join("thehum") } /// Cloned hum source tree (recipes + hive installers). pub fn src_dir() -> PathBuf { data_dir().join("src") } -/// Helper script ship with hum's source clone — used by the CLI when -/// running cross-platform service operations (systemctl / launchctl wrap). -pub fn svc_script() -> PathBuf { src_dir().join("scripts/svc.sh") } - /// `$HOME/.local` — the base for HOME-anchored installs that aren't /// resolved through XDG (cargo install --root, .local/bin, etc). pub fn local_dir() -> PathBuf { home().join(".local") } diff --git a/hum/src/main.rs b/hum/src/main.rs index 1bc72341..ca82bd94 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -176,7 +176,7 @@ fn update(force: bool) -> Result<()> { } println!("updating to {upstream_trim} …"); // Canonical installer URL is the single source of truth. It pulls - // source, builds, bounces the service via scripts/svc.sh. + // source, builds, bounces the service via the installer. let url = "https://raw.githubusercontent.com/adiled/hum/main/install"; let status = Command::new("bash") .arg("-c") @@ -251,18 +251,6 @@ fn humd_bin() -> Result { anyhow::bail!("humd binary not found (set HUM_BIN or run ./install)") } -fn svc_helper() -> Option { - // Look next to this binary's repo root or in the rsynced source. - let candidates = [ - std::env::current_exe().ok() - .and_then(|p| p.parent().map(|p| p.to_path_buf())) - .map(|p| p.join("../../scripts/svc.sh")), - Some(hum_paths::svc_script()), - Some(PathBuf::from("./scripts/svc.sh")), - ]; - candidates.into_iter().flatten().find(|p| p.exists()) -} - // ─── subcommands ───────────────────────────────────────────────────────── fn summary() -> Result<()> { @@ -294,13 +282,6 @@ fn status() -> Result<()> { println!("thrum socket: {} {}", thrum_sock.display(), yn(std::fs::metadata(&thrum_sock).is_ok())); - if let Some(svc) = svc_helper() { - println!(); - let _ = Command::new("bash") - .arg("-c") - .arg(format!(". {} && svc_status hum", svc.display())) - .status(); - } Ok(()) } @@ -417,14 +398,8 @@ fn doctor() -> Result<()> { Ok(_) | Err(_) => println!(" {claude}: ✗ NOT RUNNABLE — set CLAUDE_CLI_PATH to the real binary"), } - // 5. Bees + service state (full manifest info). println!("\n[bees]"); - if let Some(svc) = svc_helper() { - let installed = bee_list(&svc).unwrap_or_default(); - let _ = bee_list_full(&svc, &installed); - } else { - println!(" (svc.sh not found — can't enumerate services)"); - } + let _ = bee_list_full(&orch_catalog()); // 6. Recent logs with warnings/errors surfaced. This is where the // real failures show (worker.result.error, bee.hid.*, spawn fails). @@ -460,57 +435,7 @@ fn print_recent_logs(unit: &str, lines: u32) { } } -// ── hive / bee shared service plumbing ───────────────────────────────────── - -/// `svc_list` short-ids of installed bee services (the `hum-*` units). -fn bee_list(svc: &std::path::Path) -> Result> { - let out = Command::new("bash") - .arg("-c") - .arg(format!(". {} && svc_list", svc.display())) - .output() - .context("run svc_list")?; - Ok(String::from_utf8_lossy(&out.stdout) - .lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect()) -} - -/// True if a unit is currently running (svc_is_active exit 0). -fn svc_active(svc: &std::path::Path, unit: &str) -> bool { - Command::new("bash") - .arg("-c") - .arg(format!(". {} && svc_is_active {}", svc.display(), unit)) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -/// Last exit code reported by the service manager. None if unknown. -/// Non-zero with `!svc_active` means crash-loop. -fn svc_last_exit(svc: &std::path::Path, unit: &str) -> Option { - let out = Command::new("bash") - .arg("-c") - .arg(format!(". {} && svc_last_exit {}", svc.display(), unit)) - .output().ok()?; - let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); - raw.parse().ok() -} - -/// Resolve a user-given name to installed service unit(s), tolerantly: -/// exact unit ("hum-claude-cli-worker") -/// → "hum-" ("paid-oracle" → "hum-paid-oracle") -/// → hive-kind prefix ("claude-cli" → "hum-claude-cli-worker"), so the -/// kind shown by `hum hive --list` addresses its bee. -fn resolve_units(installed: &[String], name: &str) -> Vec { - if name == "all" { return installed.to_vec(); } - let prefixed = format!("hum-{name}"); - installed.iter().filter(|b| { - **b == name || **b == prefixed - || b.strip_prefix("hum-").map(|s| s == name || s.starts_with(&format!("{name}-"))).unwrap_or(false) - }).cloned().collect() -} +// ── hive / bee plumbing ───────────────────────────────────────────────── fn hive(target: Option, action: Option, list: bool) -> Result<()> { // hum hive --list (or bare `hum hive`) @@ -552,15 +477,8 @@ fn hive_list() -> Result<()> { } } } - if let Some(svc) = svc_helper() { - let catalogue: Vec = kinds.keys().cloned().collect(); - for unit in bee_list(&svc).unwrap_or_default() { - let sid = unit.strip_prefix("hum-").unwrap_or(&unit).to_string(); - let kind = catalogue.iter() - .filter(|k| sid == **k || sid.starts_with(&format!("{k}-"))) - .max_by_key(|k| k.len()).cloned().unwrap_or(sid); - kinds.entry(kind).or_default().2 = true; - } + for kind in orch_catalog() { + kinds.entry(kind).or_default().2 = true; } if kinds.is_empty() { println!("no hives found (looked in {})", hives_dir.display()); @@ -755,10 +673,7 @@ fn resolve_hive_dir(reference: &str) -> Result { anyhow::bail!("can't resolve hive '{reference}' (not a bundled name, path, or github source URL)"); } -/// Render `hum bee --list` with maximum info: humd's live manifest -/// (hid, role, models, tools, provides, wire, version, source) joined -/// with each bee's service unit + running state. -fn bee_list_full(svc: &std::path::Path, installed: &[String]) -> Result<()> { +fn bee_list_full(installed: &[String]) -> Result<()> { let snap_path = hum_paths::bees_snapshot(); let live: Vec = std::fs::read_to_string(&snap_path).ok() .and_then(|s| serde_json::from_str::(&s).ok()) @@ -774,29 +689,18 @@ fn bee_list_full(svc: &std::path::Path, installed: &[String]) -> Result<()> { let s = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); let arr = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_array()).cloned().unwrap_or_default(); - // Live bees (full info), each matched to a service unit if any. - let mut matched_units: Vec = Vec::new(); + let mut matched_kinds: Vec = Vec::new(); for m in &live { let hive = s(m, "name"); - let unit = installed.iter().find(|u| { - let sid = u.strip_prefix("hum-").unwrap_or(u); - sid == hive || sid.starts_with(&format!("{hive}-")) - }).cloned(); - if let Some(u) = &unit { matched_units.push(u.clone()); } + let managed = installed.iter().any(|k| k == &hive || hive.starts_with(&format!("{k}-"))); + if managed { matched_kinds.push(hive.clone()); } let role = arr(m, "bee").iter().filter_map(|x| x.as_str().map(str::to_string)).collect::>().join("+"); let models = arr(m, "models").iter().filter_map(|x| x.as_str().map(str::to_string)).collect::>(); let tools: Vec = arr(m, "tools").iter().map(|t| s(t, "name")).filter(|x| !x.is_empty()).collect(); let provides = arr(m, "provides").iter().filter_map(|x| x.as_str().map(str::to_string)).collect::>(); let wire = m.get("propensity").map(|p| s(p, "wire")).unwrap_or_default(); - let state = match &unit { - Some(u) if svc_active(svc, u) => "in nest (service running)".to_string(), - Some(u) => match svc_last_exit(svc, u) { - Some(code) if code != 0 => format!("⚠ crash-looping (exit {code})"), - _ => "in nest (service stopped?)".to_string(), - }, - None => "in nest (unmanaged)".to_string(), - }; + let state = if managed { "in nest (orchd-managed)" } else { "in nest (unmanaged)" }; println!("● {hive} — {state}"); let hid = s(m, "hid"); @@ -810,22 +714,12 @@ fn bee_list_full(svc: &std::path::Path, installed: &[String]) -> Result<()> { if !version.is_empty() { println!(" version: {version}"); } let source = s(m, "source"); if !source.is_empty() { println!(" source: {source}"); } - if let Some(u) = &unit { println!(" service: {u}"); } println!(); } - for u in installed { - if matched_units.contains(u) { continue; } - let state = if svc_active(svc, u) { - "service running, not handshaked".to_string() - } else { - match svc_last_exit(svc, u) { - Some(code) if code != 0 => format!("⚠ crash-looping (exit {code})"), - _ => "exited".to_string(), - } - }; - println!("● {} — {state}", u.strip_prefix("hum-").unwrap_or(u)); - println!(" service: {u}"); + for kind in installed { + if matched_kinds.contains(kind) { continue; } + println!("● {kind} — installed, not handshaked"); println!(); } @@ -834,50 +728,22 @@ fn bee_list_full(svc: &std::path::Path, installed: &[String]) -> Result<()> { } fn bee(target: Option, verb: Option, list: bool) -> Result<()> { - let svc = svc_helper().context("scripts/svc.sh not found — install hum first")?; - let installed = bee_list(&svc)?; + let installed = orch_catalog(); - // List: `hum bee --list`, or bare `hum bee`. Full info comes from - // humd's live manifest snapshot (`hum_paths::bees_snapshot()`); - // service state comes from the service manager. if list || (target.is_none() && verb.is_none()) { - bee_list_full(&svc, &installed)?; - return Ok(()); + return bee_list_full(&installed); } - // Operate: `hum bee `. let (target, verb) = match (target, verb) { (Some(t), Some(v)) => (t, v), (Some(t), None) => anyhow::bail!("hum bee {t} — enter | exit | reenter"), _ => anyhow::bail!("hum bee , or hum bee --list"), }; - // Prefer humnest for any kind it knows about; fall back to svc.sh for - // legacy units or unknown targets (svc_active/svc_last_exit helpers - // stay live so `hum bee --list` keeps working). - if target != "all" && orch_route_verb(&target, &verb)? { - return Ok(()); - } - let op = match verb.as_str() { - "enter" => "svc_start", - "exit" => "svc_stop", - "reenter" => "svc_restart", - other => anyhow::bail!("unknown verb '{other}' (enter | exit | reenter)"), - }; - let units = resolve_units(&installed, &target); - if units.is_empty() { + if !orch_route_verb(&target, &verb)? { anyhow::bail!("no bee matching '{target}'. bees: {}", if installed.is_empty() { "(none)".into() } else { installed.join(", ") }); } - let past = match verb.as_str() { "enter" => "entered", "exit" => "exited", _ => "re-entered" }; - let mut all_ok = true; - for unit in &units { - let ok = Command::new("bash").arg("-c") - .arg(format!(". {} && {} {}", svc.display(), op, unit)) - .status().map(|s| s.success()).unwrap_or(false); - all_ok &= ok; - println!(" {} {unit}", if ok { format!("✓ {past}") } else { "✗ failed".into() }); - } - if all_ok { Ok(()) } else { anyhow::bail!("one or more {verb} ops failed") } + Ok(()) } fn penny() -> Result<()> { @@ -994,12 +860,7 @@ fn orch_route_verb(kind: &str, verb: &str) -> Result { } fn uninstall() -> Result<()> { - let svc = svc_helper().context("scripts/svc.sh not found")?; - let script = format!(r#" - . {} - svc_uninstall hum || true - "#, svc.display()); - Command::new("bash").arg("-c").arg(script).status()?; + let _ = Command::new("humctl").arg("stop").status(); if let Ok(bin) = humd_bin() { let _ = std::fs::remove_file(&bin); println!("removed {}", bin.display()); From a283bf79391593db931d8e32b51d0089ba7f3130 Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sun, 31 May 2026 10:25:01 +0000 Subject: [PATCH 17/18] sweep: fold thehum::layout into hum-paths, scrub TS-era + stale refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thehum::layout module merged into hum-paths (single source of truth): pub const THEHUM_SEQ_BASENAME / THEHUM_SNAPSHOTS_SUBDIR / THEHUM_ROOT_BASENAME / THEHUM_NDJSON_EXT pub fn thehum_seq_file / thehum_snapshots_dir / thehum_root_file hum + humctl + thehum-internal callers updated. Compiler-flagged dead code (zero remaining): - removed gsm-modem::now_ms (never used after the hello-rid migration to ids::HumId::mint) - removed humd's stale "see TODO in nest::pool::Nest" comment — pool module was deleted in the orchd adoption Stale TS-era references rewritten: - claude-repl module doc dropped "Real behavior in TS lives in harness.ts" (Rust IS the implementation now); unused FSM variants Hunting/Wilting/Hushed deleted - ollama-server `images: Option>` field deleted (was an #[allow(dead_code)] placeholder for a feature that never landed) - hives/common/serve module doc points at current Cell API (cell.mmm, cell.still, raise instead of spawn) - thrum-core envelope/prim docs: drop "TS daemon" / "TS wire shape" framing - thrumd::thrum_broadcast: drop "matches the TS daemon's routing" - drone::Health: drop "the TS Assessment strings" - claude-cli/graft: drop "TS writer" / "like the TS does" - ids tests: drop "lib/id.ts encodeBase32" reference - penny load test: drop "TS shape" wording Test pass: 275. --- drone/src/lib.rs | 4 ++-- hives/claude-cli/src/graft/mod.rs | 5 ++--- hives/claude-repl/src/lib.rs | 18 ++++-------------- hives/common/src/serve.rs | 12 +++++------- hives/gsm-modem/src/main.rs | 6 ------ hives/ollama-server/src/main.rs | 3 --- hum-paths/src/lib.rs | 12 ++++++++++++ hum/src/main.rs | 2 +- humctl/src/main.rs | 6 +++--- humd/src/lib.rs | 2 -- ids/src/lib.rs | 3 +-- penny/src/lib.rs | 2 +- thehum/src/append.rs | 6 +++--- thehum/src/layout.rs | 8 -------- thehum/src/lib.rs | 6 +++--- thehum/src/read.rs | 2 +- thehum/src/retention.rs | 4 ++-- thrum-core/src/envelope.rs | 6 +++--- thrum-core/src/prims.rs | 5 ++--- thrumd/src/lib.rs | 7 +++---- 20 files changed, 48 insertions(+), 71 deletions(-) delete mode 100644 thehum/src/layout.rs diff --git a/drone/src/lib.rs b/drone/src/lib.rs index 21bd7934..eb8b4181 100644 --- a/drone/src/lib.rs +++ b/drone/src/lib.rs @@ -111,8 +111,8 @@ pub struct RawAssessment { pub reason: String, } -/// Pre-verdict mood — the TS `Assessment` strings, now isolated to the -/// `raw` channel so they don't compete with [`Verdict`]. +/// Pre-verdict mood — kebab-case strings on the `raw` channel, kept +/// separate from [`Verdict`] so they can't compete. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Health { diff --git a/hives/claude-cli/src/graft/mod.rs b/hives/claude-cli/src/graft/mod.rs index 464b72d4..cb20d4af 100644 --- a/hives/claude-cli/src/graft/mod.rs +++ b/hives/claude-cli/src/graft/mod.rs @@ -104,7 +104,7 @@ fn read_entries(path: &Path) -> Vec { continue; } if let Ok(mut v) = serde_json::from_str::(line) { - // Coerce string content into [{type:text,text:...}] like the TS does. + // Coerce string content into the canonical [{type:text,text:...}] shape. if let Some(msg) = v.get_mut("message") { if let Some(s) = msg.get("content").and_then(Value::as_str).map(str::to_owned) { msg["content"] = json!([{ "type": "text", "text": s }]); @@ -292,8 +292,7 @@ pub fn graft( /// Append AI-SDK-prompt-shaped messages to the JSONL as Claude entries. /// Returns the number of assistant turns written. Emits the minimum shape -/// Claude CLI needs (uuid/parentUuid chain, type, role, content); full -/// fidelity (real version/gitBranch/usage stats) still lives in the TS writer. +/// Claude CLI needs (uuid/parentUuid chain, type, role, content). fn append_from_prompt( path: &Path, session_id: &str, diff --git a/hives/claude-repl/src/lib.rs b/hives/claude-repl/src/lib.rs index dd1da9de..136f6209 100644 --- a/hives/claude-repl/src/lib.rs +++ b/hives/claude-repl/src/lib.rs @@ -1,12 +1,8 @@ -//! claude-repl — interactive `claude` over a PTY. v0 stub. +//! claude-repl — interactive `claude` over a PTY. //! -//! Real behavior in TS lives in `nests/claude-repl/harness.ts`: a FSM -//! (NESTING → PERCHED → HUNTING → WILTING → HUSHED/FELLED), an ANSI/DEC -//! responder, hook FIFO, and JSONL transcript synth into stream-json. -//! -//! v0: spawn the PTY, watch stdout, mark PERCHED when the prompt glyph -//! `❯` shows up. No transcript synth, no hooks, no classifier. The cell -//! compiles and runs — it just can't carry a turn. +//! Spawns the PTY, watches stdout, marks PERCHED when the prompt glyph +//! `❯` shows up. No transcript synth, no hooks, no classifier yet — the +//! cell compiles and runs but can't carry a turn. use std::io::Read; @@ -23,12 +19,6 @@ use nest::{Cell, Egg, WorkerBee}; enum HarnessState { Nesting, Perched, - #[allow(dead_code)] - Hunting, - #[allow(dead_code)] - Wilting, - #[allow(dead_code)] - Hushed, Felled, } diff --git a/hives/common/src/serve.rs b/hives/common/src/serve.rs index 2437e92d..70c2a2bf 100644 --- a/hives/common/src/serve.rs +++ b/hives/common/src/serve.rs @@ -8,14 +8,12 @@ //! `propensity`. humd registers `{model_id → client_id}` mappings. //! - **Prompt in**: humd forwards `chi:"prompt"` tones whose `modelId` //! matches one of the worker's advertised models. The worker calls -//! `WorkerBee::spawn(spec)`, then murmurs the prompt text on the -//! cell's stdin. -//! - **Chunks out**: each event from `Cell.events` becomes a +//! `WorkerBee::raise(egg)`, then feeds the prompt text on `cell.feed`. +//! - **Chunks out**: each event from `cell.mmm` becomes a //! `chi:"chunk"` tone tagged with `chunkType` + the original sid. -//! - **Cancel**: `chi:"cancel"` triggers `Cell.kill()` for the sid. -//! - **Tool result**: `chi:"tool-result"` feeds into the cell stdin -//! via the worker's tool-result encoder (currently -//! `nest::encode_tool_result`). +//! - **Cancel**: `chi:"cancel"` triggers `cell.still()` for the sid. +//! - **Tool result**: `chi:"tool-result"` feeds into the cell via the +//! worker's tool-result encoder (`nest::encode_tool_result`). //! //! Reconnect is built in — humd restarts don't strand workers; they //! re-handshake. diff --git a/hives/gsm-modem/src/main.rs b/hives/gsm-modem/src/main.rs index 7f74fad6..2581f9cf 100644 --- a/hives/gsm-modem/src/main.rs +++ b/hives/gsm-modem/src/main.rs @@ -288,9 +288,3 @@ async fn main() -> Result<()> { Err(anyhow!("serial stream ended")) } -fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} diff --git a/hives/ollama-server/src/main.rs b/hives/ollama-server/src/main.rs index 5535575e..dd437b4f 100644 --- a/hives/ollama-server/src/main.rs +++ b/hives/ollama-server/src/main.rs @@ -106,9 +106,6 @@ impl Config { struct OllamaMessage { role: String, content: String, - #[serde(default)] - #[allow(dead_code)] - images: Option>, } #[derive(Debug, Deserialize)] diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index 6f5b7799..897fceed 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -175,6 +175,18 @@ pub fn drift_dir() -> PathBuf { state_dir().join("drift") } /// thehum chi-log directory (`thehum/YYYY-MM-DD.ndjson` + seq.bin + snapshots/). pub fn thehum_dir() -> PathBuf { state_dir().join("thehum") } +pub const THEHUM_SEQ_BASENAME: &str = "seq.bin"; +pub const THEHUM_SNAPSHOTS_SUBDIR: &str = "snapshots"; +pub const THEHUM_ROOT_BASENAME: &str = "root.txt"; +pub const THEHUM_NDJSON_EXT: &str = "ndjson"; + +/// `/seq.bin` — last-persisted seq counter for a thehum at `dir`. +pub fn thehum_seq_file(dir: &std::path::Path) -> PathBuf { dir.join(THEHUM_SEQ_BASENAME) } +/// `/snapshots` — snapshot store inside a thehum at `dir`. +pub fn thehum_snapshots_dir(dir: &std::path::Path) -> PathBuf { dir.join(THEHUM_SNAPSHOTS_SUBDIR) } +/// `/root.txt` — most recent state-root hex. +pub fn thehum_root_file(dir: &std::path::Path) -> PathBuf { dir.join(THEHUM_ROOT_BASENAME) } + /// Cloned hum source tree (recipes + hive installers). pub fn src_dir() -> PathBuf { data_dir().join("src") } diff --git a/hum/src/main.rs b/hum/src/main.rs index ca82bd94..093d41bb 100644 --- a/hum/src/main.rs +++ b/hum/src/main.rs @@ -917,7 +917,7 @@ fn thehum_status() -> Result<()> { return Ok(()); } let files = thehum_ndjson_files(&dir)?; - let seq = std::fs::read(thehum::layout::seq_file(&dir)).ok().and_then(|b| { + let seq = std::fs::read(hum_paths::thehum_seq_file(&dir)).ok().and_then(|b| { if b.len() == 8 { let mut a = [0u8; 8]; a.copy_from_slice(&b); diff --git a/humctl/src/main.rs b/humctl/src/main.rs index d826018d..6c9f414e 100644 --- a/humctl/src/main.rs +++ b/humctl/src/main.rs @@ -148,14 +148,14 @@ fn thehum() -> Result<()> { ndjson.sort(); let latest = ndjson.last().cloned().unwrap_or_else(|| "(none)".to_string()); - let seq: u64 = std::fs::read(thehum::layout::seq_file(&dir)) + let seq: u64 = std::fs::read(hum_paths::thehum_seq_file(&dir)) .ok() .and_then(|b| if b.len() == 8 { let mut a = [0u8; 8]; a.copy_from_slice(&b); Some(u64::from_le_bytes(a)) } else { None }) .unwrap_or(0); - let snap_dir = thehum::layout::snapshots_dir(&dir); + let snap_dir = hum_paths::thehum_snapshots_dir(&dir); let snap_count = match std::fs::read_dir(&snap_dir) { Ok(it) => { let mut n: usize = 0; @@ -173,7 +173,7 @@ fn thehum() -> Result<()> { } } - let root = std::fs::read_to_string(thehum::layout::root_file(&dir)) + let root = std::fs::read_to_string(hum_paths::thehum_root_file(&dir)) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) diff --git a/humd/src/lib.rs b/humd/src/lib.rs index 1d91eff8..65a70f50 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -1644,8 +1644,6 @@ impl ToneSink for HumdSink { fn my_capabilities(cfg: &DaemonConfig) -> PeerCapabilities { let nest_name = cfg.hum_cfg.nest.default.clone(); let total_slots = cfg.hum_cfg.nest.max_active_cells; - // Initial advertise: full free, Cool. Live updates come from the - // beat path once the pool is wired (see TODO in nest::pool::Nest). let headroom = ensemble::headroom::CellHeadroom::from_counts(total_slots, total_slots, None); PeerCapabilities { proto_version: thrum_core::THRUM_VERSION.into(), diff --git a/ids/src/lib.rs b/ids/src/lib.rs index fc71c097..eae51b2f 100644 --- a/ids/src/lib.rs +++ b/ids/src/lib.rs @@ -246,8 +246,7 @@ mod tests { #[test] fn ts_parity_vectors() { - // Reference vectors produced by lib/id.ts encodeBase32() in Node. - // Keeps the Rust encoder bit-identical to the TS implementation. + // Fixed reference vectors so encoder output stays bit-stable. let zero = [0u8; 32]; assert_eq!(encode(&zero), "0".repeat(52)); diff --git a/penny/src/lib.rs b/penny/src/lib.rs index ad480b22..f66b2ea1 100644 --- a/penny/src/lib.rs +++ b/penny/src/lib.rs @@ -189,7 +189,7 @@ mod tests { #[test] fn load_skips_non_numeric_fields() { - // Mirrors the TS shape that included a `started: ` timestamp alongside counters. + // Loader must skip non-numeric fields (e.g. legacy `started`, `label`). let dir = tempfile::tempdir().unwrap(); let path = dir.path().join(hum_paths::PENNY_BASENAME); std::fs::write( diff --git a/thehum/src/append.rs b/thehum/src/append.rs index 6e34a869..42879c50 100644 --- a/thehum/src/append.rs +++ b/thehum/src/append.rs @@ -81,7 +81,7 @@ fn write_line(path: &Path, line: &str, fsync: bool) -> Result<()> { /// Atomic seq persistence: tmp + rename. fn persist_seq(dir: &Path, seq: Seq) -> Result<()> { - let final_path = crate::layout::seq_file(dir); + let final_path = hum_paths::thehum_seq_file(dir); let tmp = final_path.with_extension("bin.tmp"); std::fs::write(&tmp, seq.to_le_bytes())?; std::fs::rename(&tmp, &final_path).context("rename seq.bin")?; @@ -90,7 +90,7 @@ fn persist_seq(dir: &Path, seq: Seq) -> Result<()> { /// Cold-boot recovery: read seq.bin and the last line's pre-sig hash. pub fn recover_state(dir: &Path) -> Result<(Seq, Hash32)> { - let seq = std::fs::read(crate::layout::seq_file(dir)) + let seq = std::fs::read(hum_paths::thehum_seq_file(dir)) .ok() .and_then(|b| { if b.len() == 8 { @@ -119,7 +119,7 @@ fn last_line_in_dir(dir: &Path) -> Result> { .context("readdir thehum")? .filter_map(|e| e.ok()) .map(|e| e.path()) - .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ndjson")) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some(hum_paths::THEHUM_NDJSON_EXT)) .collect(); files.sort(); for path in files.iter().rev() { diff --git a/thehum/src/layout.rs b/thehum/src/layout.rs deleted file mode 100644 index 5ea9a62e..00000000 --- a/thehum/src/layout.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Single source of truth for thehum's on-disk filenames. - -use std::path::{Path, PathBuf}; - -pub fn seq_file(dir: &Path) -> PathBuf { dir.join("seq.bin") } -pub fn snapshots_dir(dir: &Path) -> PathBuf { dir.join("snapshots") } -pub fn root_file(dir: &Path) -> PathBuf { dir.join("root.txt") } -pub fn ndjson_ext() -> &'static str { "ndjson" } diff --git a/thehum/src/lib.rs b/thehum/src/lib.rs index 30354205..5c146e77 100644 --- a/thehum/src/lib.rs +++ b/thehum/src/lib.rs @@ -24,7 +24,7 @@ use tokio::sync::broadcast; pub mod anchor; pub mod append; pub mod canon; -pub mod layout; + pub mod read; pub mod retention; pub mod sign; @@ -142,8 +142,8 @@ impl TheHum { pub fn open(dir: &Path, signing_key: SigningKey, cfg: Config) -> Result { std::fs::create_dir_all(dir) .with_context(|| format!("create thehum dir {}", dir.display()))?; - std::fs::create_dir_all(layout::snapshots_dir(dir)) - .with_context(|| format!("create snapshots dir {}", layout::snapshots_dir(dir).display()))?; + std::fs::create_dir_all(hum_paths::thehum_snapshots_dir(dir)) + .with_context(|| format!("create snapshots dir {}", hum_paths::thehum_snapshots_dir(dir).display()))?; let pubkey = signing_key.verifying_key(); let author_hid = ensemble::Hid::from_pubkey( diff --git a/thehum/src/read.rs b/thehum/src/read.rs index c1360448..d96ec381 100644 --- a/thehum/src/read.rs +++ b/thehum/src/read.rs @@ -45,7 +45,7 @@ fn scan_all(dir: &Path) -> Result> { .context("readdir thehum")? .filter_map(|e| e.ok()) .map(|e| e.path()) - .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ndjson")) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some(hum_paths::THEHUM_NDJSON_EXT)) .collect(); files.sort(); diff --git a/thehum/src/retention.rs b/thehum/src/retention.rs index f297998c..9db170b2 100644 --- a/thehum/src/retention.rs +++ b/thehum/src/retention.rs @@ -57,7 +57,7 @@ fn daily_files(dir: &Path) -> Result> { Ok(std::fs::read_dir(dir)? .filter_map(|e| e.ok()) .map(|e| e.path()) - .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ndjson")) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some(hum_paths::THEHUM_NDJSON_EXT)) .collect()) } @@ -91,7 +91,7 @@ mod tests { let r = t.enforce_retention().unwrap(); assert_eq!(r.removed_files, 0); assert_eq!(std::fs::read_dir(tmp.path()).unwrap().filter(|e| { - e.as_ref().unwrap().path().extension().and_then(|x| x.to_str()) == Some("ndjson") + e.as_ref().unwrap().path().extension().and_then(|x| x.to_str()) == Some(hum_paths::THEHUM_NDJSON_EXT) }).count(), 3); } diff --git a/thrum-core/src/envelope.rs b/thrum-core/src/envelope.rs index 73a5a483..2646ff13 100644 --- a/thrum-core/src/envelope.rs +++ b/thrum-core/src/envelope.rs @@ -66,7 +66,7 @@ impl Envelope { /// The body lives in a raw `serde_json::Map` because tone shapes vary /// per chi. Code that needs strongly typed access decodes the body into /// the appropriate per-chi struct. Serializing a `Tone` flattens body -/// fields up next to the envelope ones — matching the TS wire shape. +/// fields up next to the envelope ones into one flat wire object. #[derive(Debug, Clone)] pub struct Tone { pub envelope: Envelope, @@ -93,8 +93,8 @@ impl Tone { impl Serialize for Tone { fn serialize(&self, ser: S) -> Result { - // Round-trip the envelope through Value so we can merge it with body - // into one flat object — the TS wire shape. + // Round-trip envelope through Value so we can merge it with body + // into one flat wire object. let env_value = serde_json::to_value(&self.envelope).map_err(serde::ser::Error::custom)?; let Value::Object(mut merged) = env_value else { return Err(serde::ser::Error::custom("envelope did not serialize to an object")); diff --git a/thrum-core/src/prims.rs b/thrum-core/src/prims.rs index 6e86c723..62030f25 100644 --- a/thrum-core/src/prims.rs +++ b/thrum-core/src/prims.rs @@ -64,9 +64,8 @@ mod tests { use super::*; #[test] - fn sigil_matches_ts_shape() { - // sha256("claude:abc")[..12] hex, computed once: humd and the TS daemon - // MUST agree byte-for-byte or sigils desync. + fn sigil_is_stable() { + // sha256("claude:abc")[..12] hex — pinned so peers can't desync. let s = sigil("abc", "claude"); assert_eq!(s.len(), 12); assert!(s.chars().all(|c| c.is_ascii_hexdigit())); diff --git a/thrumd/src/lib.rs b/thrumd/src/lib.rs index 3d4dc781..16e3ec68 100644 --- a/thrumd/src/lib.rs +++ b/thrumd/src/lib.rs @@ -107,10 +107,9 @@ impl Thrum { } /// Send to every client claiming the given sid's sigil. Falls back to - /// every unregistered client (no sigils claimed) if nobody owns it — - /// matches the TS daemon's routing behaviour. `nest` selects which - /// nest-kind namespace to compute the sigil under (e.g. "claude-cli", - /// "claude-repl", future nests). + /// every unregistered client (no sigils claimed) if nobody owns it. + /// `nest` selects which nest-kind namespace to compute the sigil + /// under (e.g. "claude-cli", "claude-repl"). pub fn thrum_broadcast(&self, sid: &str, nest: &str, mut tone: Tone) { let sigil = thrum_core::sigil(sid, nest); if let Some(obj) = tone.as_object_mut() { From 466fc6de9c3845ae90f143c0eef0242378a1bcdd Mon Sep 17 00:00:00 2001 From: Adil Shaikh Date: Sun, 31 May 2026 10:42:50 +0000 Subject: [PATCH 18/18] =?UTF-8?q?shrink=20public=20API:=20pub=20=E2=86=92?= =?UTF-8?q?=20pub(crate)=20where=20nothing=20external=20uses=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drove from rustc's unreachable_pub lint with RUSTFLAGS=-W unreachable_pub across the workspace. 68 items downgraded: ensemble/kad.rs 6 internal kad helpers (ParsedFindNode*, etc) thrumd/conn.rs 1 thrumd/registry.rs 7 internal sigil-broadcast types config/lib.rs 7 defaults::* helpers humd/peers.rs 1 load() called only from boot hives/common/mcp_bridge.rs 8 test-side reqwest_lite helpers hives/humfs (ast/* + tools/* + dispatch.rs) 38 intra-crate types hum-paths was excluded by design — every helper there is meant to be called from anywhere in the workspace, current consumers or not. 275 tests still pass. --- config/src/lib.rs | 14 +++++++------- ensemble/src/kad.rs | 12 ++++++------ hives/common/src/mcp_bridge.rs | 16 ++++++++-------- hives/humfs/src/ast/mod.rs | 28 ++++++++++++++-------------- hives/humfs/src/ast/outline.rs | 2 +- hives/humfs/src/ast/query.rs | 2 +- hives/humfs/src/ast/subwalk.rs | 6 +++--- hives/humfs/src/ast/symbol.rs | 8 ++++---- hives/humfs/src/dispatch.rs | 4 ++-- hives/humfs/src/tools/bash.rs | 4 ++-- hives/humfs/src/tools/do_code.rs | 4 ++-- hives/humfs/src/tools/do_noncode.rs | 4 ++-- hives/humfs/src/tools/mod.rs | 8 ++++---- hives/humfs/src/tools/read.rs | 4 ++-- humd/src/peers.rs | 2 +- thrumd/src/conn.rs | 2 +- thrumd/src/registry.rs | 14 +++++++------- 17 files changed, 67 insertions(+), 67 deletions(-) diff --git a/config/src/lib.rs b/config/src/lib.rs index f33d94c5..986c12aa 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -257,25 +257,25 @@ pub fn validate_or_exit() { mod defaults { use std::path::PathBuf; - pub fn permission_dusk_ms() -> u64 { + pub(crate) fn permission_dusk_ms() -> u64 { 60_000 } - pub fn drift_retention_days() -> u32 { + pub(crate) fn drift_retention_days() -> u32 { 30 } - pub fn metrics_addr() -> String { + pub(crate) fn metrics_addr() -> String { "127.0.0.1:9909".into() } - pub fn max_active_cells() -> u32 { + pub(crate) fn max_active_cells() -> u32 { 4 } - pub fn cell_idle_prune_threshold_ms() -> u64 { + pub(crate) fn cell_idle_prune_threshold_ms() -> u64 { 300_000 } - pub fn default_hive() -> String { + pub(crate) fn default_hive() -> String { "claude-repl".into() } - pub fn denied() -> Vec { + pub(crate) fn denied() -> Vec { vec![ PathBuf::from("~/.ssh"), PathBuf::from("~/.aws"), diff --git a/ensemble/src/kad.rs b/ensemble/src/kad.rs index e5af87e3..30d43ca1 100644 --- a/ensemble/src/kad.rs +++ b/ensemble/src/kad.rs @@ -398,7 +398,7 @@ pub(crate) struct LookupShortlist { } impl LookupShortlist { - pub fn new(target: Hid, seed: Vec) -> Self { + pub(crate) fn new(target: Hid, seed: Vec) -> Self { let mut s = Self { shortlist: Vec::new(), queried: std::collections::HashSet::new(), @@ -410,7 +410,7 @@ impl LookupShortlist { s } - pub fn insert(&mut self, addr: HumdAddr) { + pub(crate) fn insert(&mut self, addr: HumdAddr) { if self.shortlist.iter().any(|a| a.id == addr.id) { return; } @@ -423,7 +423,7 @@ impl LookupShortlist { } /// Up to α not-yet-queried entries from the closest end of the shortlist. - pub fn next_unqueried(&self, alpha: usize) -> Vec { + pub(crate) fn next_unqueried(&self, alpha: usize) -> Vec { self.shortlist .iter() .filter(|a| !self.queried.contains(&a.id)) @@ -432,19 +432,19 @@ impl LookupShortlist { .collect() } - pub fn mark_queried(&mut self, id: Hid) { + pub(crate) fn mark_queried(&mut self, id: Hid) { self.queried.insert(id); } /// Closest distance seen so far (or all-ones sentinel if empty). - pub fn closest_distance(&self) -> [u8; 32] { + pub(crate) fn closest_distance(&self) -> [u8; 32] { self.shortlist .first() .map(|a| XorDistance::distance(&self.target, &a.id)) .unwrap_or([0xff; 32]) } - pub fn closest(&self) -> Option<&HumdAddr> { + pub(crate) fn closest(&self) -> Option<&HumdAddr> { self.shortlist.first() } } diff --git a/hives/common/src/mcp_bridge.rs b/hives/common/src/mcp_bridge.rs index b9e76c34..e69f04c6 100644 --- a/hives/common/src/mcp_bridge.rs +++ b/hives/common/src/mcp_bridge.rs @@ -175,23 +175,23 @@ mod tests { use std::io::{Read, Write}; use std::net::TcpStream; - pub struct Client; + pub(crate) struct Client; impl Client { - pub fn new() -> Self { Self } - pub fn post(self, url: String) -> RequestBuilder { + pub(crate) fn new() -> Self { Self } + pub(crate) fn post(self, url: String) -> RequestBuilder { RequestBuilder { url, body: None } } } - pub struct RequestBuilder { + pub(crate) struct RequestBuilder { url: String, body: Option, } impl RequestBuilder { - pub fn json(mut self, v: &T) -> Self { + pub(crate) fn json(mut self, v: &T) -> Self { self.body = Some(serde_json::to_string(v).unwrap()); self } - pub async fn send(self) -> Result { + pub(crate) async fn send(self) -> Result { let url = self.url; let body = self.body.unwrap_or_default(); tokio::task::spawn_blocking(move || -> Result { @@ -212,9 +212,9 @@ mod tests { }).await.unwrap() } } - pub struct Response { body: String } + pub(crate) struct Response { body: String } impl Response { - pub async fn json(self) -> Result { + pub(crate) async fn json(self) -> Result { serde_json::from_str(&self.body) } } diff --git a/hives/humfs/src/ast/mod.rs b/hives/humfs/src/ast/mod.rs index 680859a2..75bd1be2 100644 --- a/hives/humfs/src/ast/mod.rs +++ b/hives/humfs/src/ast/mod.rs @@ -16,16 +16,16 @@ use std::path::Path; use tree_sitter::{Language, Node, Parser, Query, QueryCursor, StreamingIterator}; -pub mod outline; -pub mod query; -pub mod subwalk; -pub mod symbol; +pub(crate) mod outline; +pub(crate) mod query; +pub(crate) mod subwalk; +pub(crate) mod symbol; -pub use symbol::{Symbol, SymbolKind}; +pub(crate) use symbol::{Symbol, SymbolKind}; /// Recognize a code language from a file path. Returns None for /// unsupported extensions; caller falls back to the text path. -pub fn detect_language(path: &Path) -> Option { +pub(crate) fn detect_language(path: &Path) -> Option { let ext = path.extension().and_then(|s| s.to_str())?.to_ascii_lowercase(); match ext.as_str() { "rs" => Some(LangSpec::Rust), @@ -39,7 +39,7 @@ pub fn detect_language(path: &Path) -> Option { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LangSpec { +pub(crate) enum LangSpec { Rust, Python, Go, @@ -49,7 +49,7 @@ pub enum LangSpec { } impl LangSpec { - pub fn tree_sitter_language(self) -> Language { + pub(crate) fn tree_sitter_language(self) -> Language { match self { LangSpec::Rust => tree_sitter_rust::LANGUAGE.into(), LangSpec::Python => tree_sitter_python::LANGUAGE.into(), @@ -60,7 +60,7 @@ impl LangSpec { } } - pub fn name(self) -> &'static str { + pub(crate) fn name(self) -> &'static str { match self { LangSpec::Rust => "rust", LangSpec::Python => "python", @@ -74,7 +74,7 @@ impl LangSpec { /// Parse a source string against the given language. Returns a /// parser-owned tree the caller can query. -pub fn parse(source: &str, lang: LangSpec) -> Option { +pub(crate) fn parse(source: &str, lang: LangSpec) -> Option { let mut parser = Parser::new(); parser.set_language(&lang.tree_sitter_language()).ok()?; parser.parse(source, None) @@ -83,7 +83,7 @@ pub fn parse(source: &str, lang: LangSpec) -> Option { /// Run the given language's symbol query over `source`, returning /// every captured symbol sorted by start byte. Children stay in the /// returned tree's order; outline formatting handles indentation. -pub fn file_symbols(source: &str, lang: LangSpec) -> Vec { +pub(crate) fn file_symbols(source: &str, lang: LangSpec) -> Vec { let tree = match parse(source, lang) { Some(t) => t, None => return vec![], @@ -139,7 +139,7 @@ pub fn file_symbols(source: &str, lang: LangSpec) -> Vec { /// then alias segments (via `subwalk`) under the resulting AST /// node. Returns `(start_byte, end_byte, start_row, end_row)` on /// match. -pub fn resolve_path( +pub(crate) fn resolve_path( source: &str, lang: LangSpec, path: &str, @@ -192,7 +192,7 @@ fn resolve_named<'a>(symbols: &'a [Symbol], segs: &[&str]) -> Option<&'a Symbol> /// Find the smallest symbol enclosing the given byte offset. /// Useful for annotating regex hits in `humfs_read` with the /// function / class they sit inside. -pub fn enclosing_symbol(symbols: &[Symbol], byte: usize) -> Option<&Symbol> { +pub(crate) fn enclosing_symbol(symbols: &[Symbol], byte: usize) -> Option<&Symbol> { let mut best: Option<&Symbol> = None; for s in symbols { if s.start_byte <= byte && byte < s.end_byte { @@ -208,7 +208,7 @@ pub fn enclosing_symbol(symbols: &[Symbol], byte: usize) -> Option<&Symbol> { /// Return Err with a one-line message if the source has any syntax /// errors per the given language's parser. Used as a post-write /// validation gate by `humfs_do_code` (P5). -pub fn validate_syntax(source: &str, lang: LangSpec) -> Result<(), String> { +pub(crate) fn validate_syntax(source: &str, lang: LangSpec) -> Result<(), String> { let tree = parse(source, lang).ok_or_else(|| "parser unavailable".to_string())?; let root = tree.root_node(); if root.has_error() { diff --git a/hives/humfs/src/ast/outline.rs b/hives/humfs/src/ast/outline.rs index a98fc8a8..c457af3f 100644 --- a/hives/humfs/src/ast/outline.rs +++ b/hives/humfs/src/ast/outline.rs @@ -7,7 +7,7 @@ use crate::ast::Symbol; -pub fn format_symbols(symbols: &[Symbol]) -> String { +pub(crate) fn format_symbols(symbols: &[Symbol]) -> String { if symbols.is_empty() { return "(no symbols detected)".into(); } diff --git a/hives/humfs/src/ast/query.rs b/hives/humfs/src/ast/query.rs index 29b38828..8ac71ffe 100644 --- a/hives/humfs/src/ast/query.rs +++ b/hives/humfs/src/ast/query.rs @@ -14,7 +14,7 @@ use crate::ast::LangSpec; -pub fn symbol_query(lang: LangSpec) -> &'static str { +pub(crate) fn symbol_query(lang: LangSpec) -> &'static str { match lang { LangSpec::Rust => RUST_QUERY, LangSpec::Python => PYTHON_QUERY, diff --git a/hives/humfs/src/ast/subwalk.rs b/hives/humfs/src/ast/subwalk.rs index aed25634..caa8c530 100644 --- a/hives/humfs/src/ast/subwalk.rs +++ b/hives/humfs/src/ast/subwalk.rs @@ -28,13 +28,13 @@ use crate::ast::LangSpec; /// vocabulary words; `occurrence` is the 1-based ordinal (1 for /// no-`#N` segments). #[derive(Debug, Clone)] -pub struct AliasSegment { +pub(crate) struct AliasSegment { pub alias: String, pub occurrence: usize, } /// Parse "when#2" → `AliasSegment { alias: "when", occurrence: 2 }`. -pub fn parse_segment(raw: &str) -> Option { +pub(crate) fn parse_segment(raw: &str) -> Option { let (alias, occurrence) = match raw.split_once('#') { Some((a, n)) => (a.to_string(), n.parse().ok()?), None => (raw.to_string(), 1usize), @@ -49,7 +49,7 @@ pub fn parse_segment(raw: &str) -> Option { /// Resolve a path of alias segments under `root`, returning the /// final matching node. Each segment's match becomes the scope for /// the next segment. -pub fn resolve_subpath<'tree>( +pub(crate) fn resolve_subpath<'tree>( mut root: Node<'tree>, segs: &[AliasSegment], lang: LangSpec, diff --git a/hives/humfs/src/ast/symbol.rs b/hives/humfs/src/ast/symbol.rs index 3a7a7c8b..121f0716 100644 --- a/hives/humfs/src/ast/symbol.rs +++ b/hives/humfs/src/ast/symbol.rs @@ -2,7 +2,7 @@ //! captured by a language's symbol query. #[derive(Debug, Clone)] -pub struct Symbol { +pub(crate) struct Symbol { pub name: String, pub kind: SymbolKind, /// Byte range over the source. Half-open `[start_byte, end_byte)`. @@ -14,7 +14,7 @@ pub struct Symbol { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SymbolKind { +pub(crate) enum SymbolKind { Function, Method, Class, // class / struct / impl / trait / interface @@ -28,7 +28,7 @@ pub enum SymbolKind { } impl SymbolKind { - pub fn from_tag(tag: &str) -> Self { + pub(crate) fn from_tag(tag: &str) -> Self { match tag { "fn" | "function" => SymbolKind::Function, "method" => SymbolKind::Method, @@ -43,7 +43,7 @@ impl SymbolKind { } } - pub fn tag(self) -> &'static str { + pub(crate) fn tag(self) -> &'static str { match self { SymbolKind::Function => "fn", SymbolKind::Method => "method", diff --git a/hives/humfs/src/dispatch.rs b/hives/humfs/src/dispatch.rs index 0cdca7a7..f48d62ba 100644 --- a/hives/humfs/src/dispatch.rs +++ b/hives/humfs/src/dispatch.rs @@ -10,12 +10,12 @@ use serde_json::Value; use crate::tools::{bash, do_code, do_noncode, read}; -pub struct HumfsDispatcher { +pub(crate) struct HumfsDispatcher { // Future: SessionState here (cwd, fs.roots, permission cache). } impl HumfsDispatcher { - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self {} } } diff --git a/hives/humfs/src/tools/bash.rs b/hives/humfs/src/tools/bash.rs index 549f4dd7..fe77c515 100644 --- a/hives/humfs/src/tools/bash.rs +++ b/hives/humfs/src/tools/bash.rs @@ -47,7 +47,7 @@ struct Args { timeout: Option, } -pub fn def() -> ToolDef { +pub(crate) fn def() -> ToolDef { ToolDef { name: "humfs_bash".into(), description: "Execute a shell command under the session cwd. Use for runtime work: running tests, git operations, build scripts, package managers, language toolchains, CLI utilities. File inspection (ls/find/grep/rg/cat/head/tail/sed/awk/cut/uniq/wc/more/less/tree/du/file/od/xxd/strings/zcat/bzcat/xzcat/zgrep/xargs) routes back to humfs_read; the filter applies post-unwrap so wrapping in bash -c / sh -c / env / shell functions still resolves to humfs_read. File authoring (>, >>, tee, dd, cp, mv, rm, mkdir, touch, chmod, ln, scripting one-liners that write) routes to humfs_do_code / humfs_do_noncode; allowlisted runtime invocations (git, npm/yarn/pnpm/bun, pip/uv/cargo/go, make, docker, tsc, pytest/jest, gcc/clang) pass through. Output capped at 30KB per stream; default timeout 120000ms.".into(), @@ -63,7 +63,7 @@ pub fn def() -> ToolDef { } } -pub async fn run(args: Value) -> ToolResult { +pub(crate) async fn run(args: Value) -> ToolResult { let args: Args = match serde_json::from_value(args) { Ok(a) => a, Err(e) => return ToolResult::error(format!("invalid args: {e}")), diff --git a/hives/humfs/src/tools/do_code.rs b/hives/humfs/src/tools/do_code.rs index 0a36362d..7a3d6402 100644 --- a/hives/humfs/src/tools/do_code.rs +++ b/hives/humfs/src/tools/do_code.rs @@ -42,7 +42,7 @@ struct Args { fn default_op() -> String { "replace".into() } -pub fn def() -> ToolDef { +pub(crate) fn def() -> ToolDef { ToolDef { name: "humfs_do_code".into(), description: "Author code — AST-grounded, symbol-scoped. Operations: create | replace (symbol OR whole-file) | insert_before | insert_after | delete. The top-of-file import block is addressable as the synthetic 'imports' symbol. Sub-symbol walks (body/when/otherwise/loop/try/return/call) compose with dots and disambiguate with #N (P6). Languages: ts/tsx/js/jsx/mjs/cjs/py/pyi/go/rs (AST-backed today). Every write is re-parsed; a syntax-error result aborts the write. Non-code files route to humfs_do_noncode.".into(), @@ -59,7 +59,7 @@ pub fn def() -> ToolDef { } } -pub async fn run(args: Value) -> ToolResult { +pub(crate) async fn run(args: Value) -> ToolResult { let args: Args = match serde_json::from_value(args) { Ok(a) => a, Err(e) => return ToolResult::error(format!("invalid args: {e}")), diff --git a/hives/humfs/src/tools/do_noncode.rs b/hives/humfs/src/tools/do_noncode.rs index 0f1b60b1..c5ac08fd 100644 --- a/hives/humfs/src/tools/do_noncode.rs +++ b/hives/humfs/src/tools/do_noncode.rs @@ -46,7 +46,7 @@ struct Args { replace: Option, } -pub fn def() -> ToolDef { +pub(crate) fn def() -> ToolDef { ToolDef { name: "humfs_do_noncode".into(), description: "Author non-code files using linguistic scope. Four scopes (pass exactly one): word (format-agnostic token swap), phrase (structural name — JSON/YAML key, env var, markdown heading, TOML section — or exact text), sentence (smallest independent unit), paragraph (full block). Omit 'replace' to delete the scope; no scope param creates/overwrites the whole file. Handles configs, docs, markup, stylesheets, data, plain text. Code files route to humfs_do_code.".into(), @@ -65,7 +65,7 @@ pub fn def() -> ToolDef { } } -pub async fn run(args: Value) -> ToolResult { +pub(crate) async fn run(args: Value) -> ToolResult { let args: Args = match serde_json::from_value(args) { Ok(a) => a, Err(e) => return ToolResult::error(format!("invalid args: {e}")), diff --git a/hives/humfs/src/tools/mod.rs b/hives/humfs/src/tools/mod.rs index 9e4a22ee..cb29bf48 100644 --- a/hives/humfs/src/tools/mod.rs +++ b/hives/humfs/src/tools/mod.rs @@ -1,4 +1,4 @@ -pub mod bash; -pub mod do_code; -pub mod do_noncode; -pub mod read; +pub(crate) mod bash; +pub(crate) mod do_code; +pub(crate) mod do_noncode; +pub(crate) mod read; diff --git a/hives/humfs/src/tools/read.rs b/hives/humfs/src/tools/read.rs index e352d6e1..3dee7220 100644 --- a/hives/humfs/src/tools/read.rs +++ b/hives/humfs/src/tools/read.rs @@ -58,7 +58,7 @@ struct Args { pattern: Option, } -pub fn def() -> ToolDef { +pub(crate) fn def() -> ToolDef { ToolDef { name: "humfs_read".into(), description: "Filesystem analysis: discover, study, and search. Works on any file — code returns a tree-sitter symbol outline (P4+); configs and docs return an anchor outline; extensionless files (Dockerfile, Makefile, LICENSE) and unknown extensions return content. Path auto-detection: file | directory | glob (presence of * or ?). Pick at most one modifier: symbol (exact, dot-nested for nested members), query (fuzzy case-insensitive substring match on symbol NAMES), pattern (regex over CONTENT — code matches carry their enclosing function/class symbol in P4+). The tool decides framing; no offset, no limit, no pagination.".into(), @@ -75,7 +75,7 @@ pub fn def() -> ToolDef { } } -pub async fn run(args: Value) -> ToolResult { +pub(crate) async fn run(args: Value) -> ToolResult { let args: Args = match serde_json::from_value(args) { Ok(a) => a, Err(e) => return ToolResult::error(format!("invalid args: {e}")), diff --git a/humd/src/peers.rs b/humd/src/peers.rs index 8c5edd8f..682f36ef 100644 --- a/humd/src/peers.rs +++ b/humd/src/peers.rs @@ -61,7 +61,7 @@ pub fn peers_path() -> PathBuf { /// Missing file → empty vec. Parse errors on the outer object → empty vec /// (warn). Malformed rows inside `peers[]` → skipped (warn), good rows /// kept. -pub fn load() -> Vec { +pub(crate) fn load() -> Vec { let path = peers_path(); let raw = match std::fs::read_to_string(&path) { Ok(s) => s, diff --git a/thrumd/src/conn.rs b/thrumd/src/conn.rs index b166c73e..5fcd0e1a 100644 --- a/thrumd/src/conn.rs +++ b/thrumd/src/conn.rs @@ -15,7 +15,7 @@ use crate::{ breath_tone, chi_of, echo_tone, rid_of, short, tone_is_dusk, validate_envelope, Thrum, }; -pub async fn run(thrum: Thrum, sock: UnixStream) { +pub(crate) async fn run(thrum: Thrum, sock: UnixStream) { let client_id = ids::HumId::mint().to_string(); let (reach, rx) = Reach::new(client_id.clone()); let reach = Arc::new(reach); diff --git a/thrumd/src/registry.rs b/thrumd/src/registry.rs index 82404fba..822f0756 100644 --- a/thrumd/src/registry.rs +++ b/thrumd/src/registry.rs @@ -53,32 +53,32 @@ impl Reach { } } -pub struct Registry { +pub(crate) struct Registry { by_id: HashMap>, } impl Registry { - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self { by_id: HashMap::new() } } - pub fn insert(&mut self, reach: std::sync::Arc) { + pub(crate) fn insert(&mut self, reach: std::sync::Arc) { self.by_id.insert(reach.client_id.clone(), reach); } - pub fn remove(&mut self, client_id: &str) { + pub(crate) fn remove(&mut self, client_id: &str) { self.by_id.remove(client_id); } - pub fn get(&self, client_id: &str) -> Option<&Reach> { + pub(crate) fn get(&self, client_id: &str) -> Option<&Reach> { self.by_id.get(client_id).map(|r| r.as_ref()) } - pub fn iter(&self) -> impl Iterator { + pub(crate) fn iter(&self) -> impl Iterator { self.by_id.values().map(|r| r.as_ref()) } - pub fn len(&self) -> usize { + pub(crate) fn len(&self) -> usize { self.by_id.len() } }