From d0f954bbc1acdf008a9684e930a5c00d9a044752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Thu, 14 May 2026 23:39:06 +0200 Subject: [PATCH] Phase 6a: per-container web UI (axum); per-agent port hashed from name --- Cargo.toml | 1 + hive-ag3nt/Cargo.toml | 1 + hive-ag3nt/src/bin/hive-ag3nt.rs | 16 +++++++++++-- hive-ag3nt/src/bin/hive-m1nd.rs | 16 +++++++++++-- hive-ag3nt/src/lib.rs | 4 ++++ hive-ag3nt/src/web_ui.rs | 40 ++++++++++++++++++++++++++++++++ hive-c0re/src/lifecycle.rs | 23 ++++++++++++++++++ nix/modules/hive-c0re.nix | 11 +++++++++ nix/templates/manager.nix | 4 ++++ 9 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 hive-ag3nt/src/web_ui.rs diff --git a/Cargo.toml b/Cargo.toml index a19750c..7f34871 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ must_use_candidate = "allow" [workspace.dependencies] anyhow = "1" +axum = "0.8" clap = { version = "4", features = ["derive"] } hive-sh4re = { path = "hive-sh4re" } rusqlite = { version = "0.37", features = ["bundled"] } diff --git a/hive-ag3nt/Cargo.toml b/hive-ag3nt/Cargo.toml index 8154257..09ae091 100644 --- a/hive-ag3nt/Cargo.toml +++ b/hive-ag3nt/Cargo.toml @@ -8,6 +8,7 @@ workspace = true [dependencies] anyhow.workspace = true +axum.workspace = true clap.workspace = true hive-sh4re.workspace = true serde.workspace = true diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index f8a66f9..6796bca 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; -use hive_ag3nt::{DEFAULT_SOCKET, client}; +use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui}; use hive_sh4re::{AgentRequest, AgentResponse}; use tokio::process::Command; @@ -44,7 +44,19 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { - Cmd::Serve { poll_ms } => serve(&cli.socket, Duration::from_millis(poll_ms)).await, + Cmd::Serve { poll_ms } => { + let port = std::env::var("HIVE_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_WEB_PORT); + let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into()); + tokio::spawn(async move { + if let Err(e) = web_ui::serve(label, port).await { + tracing::error!(error = ?e, "web ui failed"); + } + }); + serve(&cli.socket, Duration::from_millis(poll_ms)).await + } Cmd::Send { to, body } => { let resp: AgentResponse = client::request(&cli.socket, &AgentRequest::Send { to, body }).await?; diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index 89fb821..9b704dc 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -8,7 +8,7 @@ use std::time::Duration; use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; -use hive_ag3nt::{DEFAULT_SOCKET, client}; +use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui}; use hive_sh4re::{ManagerRequest, ManagerResponse}; #[derive(Parser)] @@ -52,7 +52,19 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { - Cmd::Serve { poll_ms } => serve(&cli.socket, Duration::from_millis(poll_ms)).await, + Cmd::Serve { poll_ms } => { + let port = std::env::var("HIVE_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_WEB_PORT); + let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into()); + tokio::spawn(async move { + if let Err(e) = web_ui::serve(label, port).await { + tracing::error!(error = ?e, "web ui failed"); + } + }); + serve(&cli.socket, Duration::from_millis(poll_ms)).await + } Cmd::Send { to, body } => one_shot(&cli.socket, ManagerRequest::Send { to, body }).await, Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await, Cmd::Spawn { name } => one_shot(&cli.socket, ManagerRequest::Spawn { name }).await, diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index 3890473..a5608ad 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -2,6 +2,10 @@ //! `hive-m1nd` (manager) binaries. pub mod client; +pub mod web_ui; /// Default socket path inside the container — bind-mounted by `hive-c0re`. pub const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock"; + +/// Default web UI port — used when `HIVE_PORT` env is unset. +pub const DEFAULT_WEB_PORT: u16 = 8042; diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs new file mode 100644 index 0000000..141b9c6 --- /dev/null +++ b/hive-ag3nt/src/web_ui.rs @@ -0,0 +1,40 @@ +//! Per-container HTTP UI. Phase 6 minimum — a status page on a host port. +//! Containers share the host's network namespace (privateNetwork = false), so +//! each instance must bind a distinct port. `HIVE_PORT` is set per agent by +//! `hive-c0re`'s generated per-agent flake (deterministic from agent name). + +use std::net::SocketAddr; + +use anyhow::{Context, Result}; +use axum::{Router, extract::State, response::Html, routing::get}; + +#[derive(Clone)] +struct AppState { + label: String, +} + +pub async fn serve(label: String, port: u16) -> Result<()> { + let state = AppState { label }; + let app = Router::new().route("/", get(index)).with_state(state); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("bind web UI on port {port}"))?; + tracing::info!(%port, "web UI listening"); + axum::serve(listener, app).await?; + Ok(()) +} + +async fn index(State(state): State) -> Html { + Html(format!( + "\n\ + \n\ + {label}\n\ + \n\ +

{label}

\n\ +

hyperhive harness placeholder. Phase 6a: this page exists.

\n\ + \n\ + \n", + label = state.label, + )) +} diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index fd3555f..d8b586f 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -19,6 +19,24 @@ pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive"; const GIT_NAME: &str = "hive-c0re"; const GIT_EMAIL: &str = "hive-c0re@hyperhive"; +/// Sub-agent web UI port range. Deterministic from the agent's name (FNV-1a +/// hash mod range size), so the dashboard can compute the same port without +/// asking hive-c0re. +const WEB_PORT_BASE: u16 = 8100; +const WEB_PORT_RANGE: u16 = 900; + +/// Returns the per-agent web UI port. Same hash on both sides — manager, +/// dashboard, and agent harness all agree. +#[must_use] +pub fn agent_web_port(name: &str) -> u16 { + let mut hash: u32 = 2_166_136_261; + for b in name.bytes() { + hash ^= u32::from(b); + hash = hash.wrapping_mul(16_777_619); + } + WEB_PORT_BASE + (hash % u32::from(WEB_PORT_RANGE)) as u16 +} + pub fn container_name(name: &str) -> String { format!("{AGENT_PREFIX}{name}") } @@ -125,6 +143,7 @@ pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str std::fs::create_dir_all(applied_dir) .with_context(|| format!("create {}", applied_dir.display()))?; + let port = agent_web_port(name); let flake_body = format!( r#"{{ description = "hyperhive sub-agent {name}"; @@ -143,6 +162,10 @@ pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str [init] defaultBranch = main ''; + systemd.services.hive-ag3nt.environment = {{ + HIVE_PORT = "{port}"; + HIVE_LABEL = "{name}"; + }}; }} ]; }}; diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 96b8f34..c3c4cfd 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -31,6 +31,17 @@ in config = lib.mkIf cfg.enable { environment.systemPackages = [ cfg.package ]; + # Per-container web UIs share the host's network namespace and need their + # ports reachable. Manager: 8000. Sub-agents: 8100..8999 (deterministic + # hash; see `lifecycle::agent_web_port`). + networking.firewall.allowedTCPPorts = [ 8000 ]; + networking.firewall.allowedTCPPortRanges = [ + { + from = 8100; + to = 8999; + } + ]; + systemd.services.hive-c0re = { description = "hyperhive coordinator daemon"; wantedBy = [ "multi-user.target" ]; diff --git a/nix/templates/manager.nix b/nix/templates/manager.nix index 3bf9e87..7920ec4 100644 --- a/nix/templates/manager.nix +++ b/nix/templates/manager.nix @@ -23,6 +23,10 @@ description = "hive-m1nd manager harness"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; + environment = { + HIVE_PORT = "8000"; + HIVE_LABEL = "hm1nd"; + }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve"; Restart = "on-failure";