diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 655693a2..53ba20d9 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -187,7 +187,7 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> anyhow::Result HashMap { out } +/// First 12 characters of a rev. Counts chars, not bytes: the value comes +/// from `flake.lock` JSON, and a byte slice at 12 can split a multi-byte char. +fn short_rev(rev: &str) -> String { + rev.chars().take(12).collect() +} + #[cfg(test)] mod tests { - use super::parse_locked_revs; + use super::{parse_locked_revs, short_rev}; + + #[test] + fn short_rev_truncates_and_keeps_short() { + assert_eq!(short_rev("0123456789abcdef"), "0123456789ab"); + assert_eq!(short_rev("abc123"), "abc123"); + } + + #[test] + fn short_rev_survives_multibyte_at_boundary() { + // 'é' is 2 bytes and straddles byte index 12. + let rev = "0123456789aébcdef"; + assert_eq!(short_rev(rev), "0123456789aé"); + } /// Shape of a real `meta/flake.lock`: `root` names a node whose /// `inputs` map alias → node name, and each node carries `locked.rev`. diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 676aaf1d..7e1741bd 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -37,7 +37,8 @@ const FORGE_CONTAINER: &str = "hive-forge"; /// /// # Panics /// -/// When `HIVE_FORGE_URL` is unset. That is deliberate: this daemon only +/// When `HIVE_FORGE_URL` is unset (`validate_env` rejects that at startup, so +/// the daemon never reaches this). That is deliberate: this daemon only /// runs under the NixOS module, which always sets it, so an unset var /// means the deployment is broken. There is no loopback fallback, /// because a guess is wrong in exactly the cases that matter — the @@ -55,6 +56,22 @@ pub(crate) fn forge_http_base() -> &'static str { }) } +/// Check the env the forge integration cannot run without: `HIVE_FORGE_URL` +/// set and a valid URL, `HIVE_ASSETS_DIR` set. Called once from `main` +/// before any worker is spawned, so a missing or malformed value fails the +/// unit start naming the variable; the lazy accessors then never hit their +/// `expect`s. +pub(crate) fn validate_env() -> Result<()> { + let url = std::env::var("HIVE_FORGE_URL").context( + "HIVE_FORGE_URL is unset (the hyperhive NixOS module sets it on the hive-c0re unit)", + )?; + Url::parse(&url).with_context(|| format!("HIVE_FORGE_URL is not a valid URL: {url:?}"))?; + std::env::var("HIVE_ASSETS_DIR").context( + "HIVE_ASSETS_DIR is unset (the hyperhive NixOS module sets it on the hive-c0re unit)", + )?; + Ok(()) +} + /// Git remote for `repo` (e.g. `"core/meta"`) — **credential-free**. /// /// The token does not go here. A URL is a process argument, and `argv` is diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 06eb05eb..ec9666ef 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -219,6 +219,9 @@ async fn main() -> Result<()> { // succeeded. lifecycle::network_isolation_from_env() .context("network isolation settings are required at startup")?; + // The forge integration is mandatory (the NixOS module sets both + // vars unconditionally), so check them before any worker starts. + forge::validate_env()?; cmd_serve(sc.env, sc.model_prices, sc.build_slots, db, &cli.socket).await } } diff --git a/hive-forge/src/verbs/pr_status.rs b/hive-forge/src/verbs/pr_status.rs index 3802f6cf..226b3b3b 100644 --- a/hive-forge/src/verbs/pr_status.rs +++ b/hive-forge/src/verbs/pr_status.rs @@ -263,9 +263,15 @@ fn status_mark(status: &str) -> &'static str { } } +/// First 12 characters of a sha. Counts chars, not bytes, so a multi-byte +/// char at the cut cannot panic. +fn short_sha(sha: &str) -> String { + sha.chars().take(12).collect() +} + /// Render the CI section (overall verdict + per-context rows). fn print_ci(sha: &str, state: &str, statuses: &[Value]) { - let short = &sha[..sha.len().min(12)]; + let short = short_sha(sha); if statuses.is_empty() { println!("{short}: no CI statuses reported (CI may not have started)"); return; @@ -462,12 +468,17 @@ mod tests { #[test] fn short_sha_truncates() { let sha = "0123456789abcdef0123456789abcdef"; - assert_eq!(&sha[..sha.len().min(12)], "0123456789ab"); + assert_eq!(short_sha(sha), "0123456789ab"); } #[test] fn short_sha_handles_already_short() { - assert_eq!(&"abc123"[.."abc123".len().min(12)], "abc123"); + assert_eq!(short_sha("abc123"), "abc123"); + } + + #[test] + fn short_sha_survives_multibyte_at_boundary() { + assert_eq!(short_sha("0123456789aébcdef"), "0123456789aé"); } #[test]