From 246dd19390f8595f119551f982df64ec18cb4260 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 29 May 2026 02:11:54 +0200 Subject: [PATCH 1/4] nix: split static runtime assets into their own derivation (#555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoists the project's branding/* + hive-ag3nt/prompts/* out of the rust derivation's src set. Lives as `packages..assets` (also exported as `pkgs.hyperhive-assets` via the default overlay). Output layout: $out/share/hyperhive/branding/{hyperhive,agent-configs}.{svg,png} $out/share/hyperhive/prompts/{system.md,claude-settings.json} `agent-configs.png` is rendered at build time from its SVG via rsvg-convert — same shape as the old `hive-c0re/build.rs` rasteriser, just hoisted into nix so the librsvg dependency stays *here* instead of in the rust derivation. No consumer change yet — the rust binaries still `include_bytes!` their copies from the in-source paths; later commits in this PR cut those over to runtime loads from `$HIVE_ASSETS_DIR/share/hyperhive/`. Why split: `src = ./.;` on the crane derivation invalidates the cargo cache on every edit to anything in the repo, including branding tweaks + prompt edits + docs. Splitting these out is the first step toward dropping the rust src input down to `craneLib.cleanCargoSource ./.` (the eventual end-state in the final commit of this PR). --- flake.nix | 10 +++++++ nix/assets.nix | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 nix/assets.nix diff --git a/flake.nix b/flake.nix index b20c18f9..5309e657 100644 --- a/flake.nix +++ b/flake.nix @@ -106,6 +106,12 @@ frontend = pkgs.callPackage ./nix/frontend.nix { branding-svg = ./branding/hyperhive.svg; }; + # Static runtime assets the rust binaries read via + # `assets::path()` (#555): branding/* + hive-ag3nt/prompts/*, + # plus the rendered agent-configs.png. Split out of the + # rust derivation so a tweak to e.g. system.md doesn't bust + # the cargo cache. + assets = pkgs.callPackage ./nix/assets.nix { }; # Pre-built per-container system closures. Exposed as packages # so operators can `nix build .#agent-base-toplevel` (or wire # them into their host system closure via the @@ -137,6 +143,10 @@ # is applied (manager + agent containers both apply it via # `mkContainer` further down). hyperhive-frontend = self.packages.${prev.stdenv.hostPlatform.system}.frontend; + # Static runtime assets (#555). Exposed alongside the binary + # so the harness module can wire $HIVE_ASSETS_DIR straight + # to `${pkgs.hyperhive-assets}/share/hyperhive`. + hyperhive-assets = self.packages.${prev.stdenv.hostPlatform.system}.assets; }; claude-unstable = final: prev: diff --git a/nix/assets.nix b/nix/assets.nix new file mode 100644 index 00000000..8fc83416 --- /dev/null +++ b/nix/assets.nix @@ -0,0 +1,77 @@ +{ + stdenv, + lib, + librsvg, +}: + +# Static assets the rust workspace reads at runtime: the project's +# branding SVG/PNG family + the claude system-prompt template + +# claude-settings JSON. Lives as its own derivation so a tweak to +# branding/agent-configs.svg or hive-ag3nt/prompts/system.md doesn't +# invalidate the rust derivation's cargo cache (closes #555 follow-up +# to #538 — naersk previously paired with `src = ./.;` invalidating +# every rust build on any branding/prompt edit; crane inherited that +# coupling and this split breaks it cleanly). +# +# Output layout: +# +# $out/share/hyperhive/branding/{hyperhive.svg, hyperhive.png, +# agent-configs.svg, agent-configs.png} +# $out/share/hyperhive/prompts/{system.md, claude-settings.json} +# +# The agent-configs PNG is rendered at build time from the SVG via +# rsvg-convert — same shape as the old `hive-c0re/build.rs` rasteriser, +# just hoisted into nix so the librsvg dependency stays *here* instead +# of in the rust derivation's nativeBuildInputs. + +stdenv.mkDerivation { + pname = "hyperhive-assets"; + version = "0.1.0"; + # `src` is intentionally narrow — only branding/ + the hive-ag3nt/prompts/ + # subdir, NOT the whole tree. Keeps the input hash decoupled from + # rust source / docs / nix module edits. + srcs = [ + ../branding + ../hive-ag3nt/prompts + ]; + # `unpackPhase` would normally extract each src to its own dir; we + # just want them side-by-side, so hand-roll a flat copy. + unpackPhase = '' + runHook preUnpack + cp -r ${../branding} branding + cp -r ${../hive-ag3nt/prompts} prompts + chmod -R u+w branding prompts + runHook postUnpack + ''; + + nativeBuildInputs = [ librsvg ]; + + # No real build step — just render the agent-configs PNG alongside + # its SVG. 300×300 matches branding/hyperhive.png, which is the size + # Forgejo's avatar endpoint accepts without resampling on upload (the + # same constraint hive-c0re/build.rs encoded). + buildPhase = '' + runHook preBuild + rsvg-convert --width 300 --height 300 \ + -o branding/agent-configs.png \ + branding/agent-configs.svg + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/share/hyperhive + cp -r branding $out/share/hyperhive/branding + cp -r prompts $out/share/hyperhive/prompts + runHook postInstall + ''; + + # Pure data — no executables to fixup, no shared libs to patchelf. + dontFixup = true; + + meta = { + description = "hyperhive static assets (branding + claude prompts)"; + homepage = "https://forge.darkest.space/hyperhive/hyperhive"; + license = lib.licenses.mit; + }; +} From 73684fb00adf421c0c5a0c47e5b241a26c495cfc Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 29 May 2026 02:41:29 +0200 Subject: [PATCH 2/4] rust+nix: load static assets at runtime, drop build.rs (#555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuts every `include_bytes!`/`include_str!` of a non-rust path in the workspace over to runtime file loads from `$HIVE_ASSETS_DIR` (the `hyperhive-assets` derivation introduced in the previous commit). After this commit the rust derivation has no compile-time dependency on `branding/*` or `hive-ag3nt/prompts/*` anymore. Call-site flips: - `hive-c0re/src/forge.rs::CORE_AVATAR_PNG` / `CONFIG_ORG_AVATAR_PNG`: were `include_bytes!` of `branding/hyperhive.png` and `$OUT_DIR/agent-configs.png`. Now `ensure_core_avatar` / `ensure_config_org_avatar` `tokio::fs::read` via `hive_sh4re::assets::{core_avatar_png, config_org_avatar_png}` at startup. The `agent-configs.png` is now rendered by the `hyperhive-assets` derivation's rsvg-convert step (was `hive-c0re/build.rs` + librsvg on the rust derivation's nativeBuildInputs — both gone in the next commit). - `hive-ag3nt/src/prompt.rs::TEMPLATE`: `render` now takes the template as an argument; `write_system_prompt` reads it once from `$HIVE_ASSETS_DIR/prompts/system.md` before calling render. The test module still `include_str!`s the production template so `cargo test --workspace` doesn't need `HIVE_ASSETS_DIR` set — this is the only remaining compile-time reference to the file from the rust workspace, gated to `#[cfg(test)]`. - `hive-ag3nt/src/turn.rs::CLAUDE_SETTINGS`: was `include_str!`'d and written via `tokio::fs::write`; now `tokio::fs::copy` from `$HIVE_ASSETS_DIR/prompts/claude-settings.json` into the per-agent socket dir. - `hive-ag3nt/src/web_ui.rs::DEFAULT_ICON`: was `include_str!`'d; now read on-demand from `$HIVE_ASSETS_DIR/branding/hyperhive.svg` inside `serve_icon`. Falls back to an empty body if missing so the endpoint never panics on a misconfigured container (matches the existing "per-agent icon.svg override" fallthrough). `HIVE_ASSETS_DIR` wiring: - Inside containers: `nix/templates/harness-base.nix` `environment.variables` sets it to `${pkgs.hyperhive-assets}/share/hyperhive` (resolved through the default overlay applied in `mkContainer`). Verified by building `agent-base-toplevel` and grepping the resulting `/etc/set-environment`. - Host-side: `nix/modules/hive-c0re.nix` adds an `assets` option defaulting to `hyperhive.packages.${system}.assets`, threaded in from the flake's nixosModules wiring, and sets the same env var on the `hive-c0re` systemd unit so the daemon's `forge::ensure_*_avatar` startup hooks find the PNGs. `hive-c0re/build.rs` deleted entirely; `[package].build` removed from `hive-c0re/Cargo.toml`; rsvg-convert dependency lives in the assets derivation only. Validated: `nix build .#default .#checks.x86_64-linux.clippy .#agent-base-toplevel .#manager-toplevel --fallback` all succeed. `/etc/set-environment` in the toplevel shows `HIVE_ASSETS_DIR="/nix/store/.../hyperhive-assets-0.1.0/share/hyperhive"`. --- flake.nix | 9 ++-- hive-ag3nt/src/prompt.rs | 40 ++++++++++---- hive-ag3nt/src/turn.rs | 36 +++++++------ hive-ag3nt/src/web_ui.rs | 12 +++-- hive-c0re/Cargo.toml | 7 --- hive-c0re/build.rs | 49 ----------------- hive-c0re/src/forge.rs | 34 ++++++------ hive-sh4re/src/assets.rs | 99 ++++++++++++++++++++++++++++++++++ hive-sh4re/src/lib.rs | 2 + nix/modules/hive-c0re.nix | 19 +++++++ nix/templates/harness-base.nix | 5 ++ 11 files changed, 207 insertions(+), 105 deletions(-) delete mode 100644 hive-c0re/build.rs create mode 100644 hive-sh4re/src/assets.rs diff --git a/flake.nix b/flake.nix index 5309e657..8aaa2491 100644 --- a/flake.nix +++ b/flake.nix @@ -68,16 +68,16 @@ }; # Shared between buildDepsOnly + buildPackage + cargoClippy # so the three derivations see the same toolchain shape. - # librsvg: hive-c0re/build.rs invokes `rsvg-convert` to - # render branding/agent-configs.svg → PNG that the daemon - # `include_bytes!`s (#424); SVG stays source-of-truth. # git: naersk used to auto-include it; crane is more # minimal, so we add it explicitly so hive-c0re's # `lifecycle::tests::setup_proposed_*` (which shell out to # `git init` + commit) pass under `cargo test` in the # sandbox. + # `librsvg` used to live here for `hive-c0re/build.rs`'s + # rsvg-convert call — that whole codepath moved into the + # `hyperhive-assets` derivation in #555, so the rust + # derivation no longer needs the dependency. nativeBuildInputs = [ - pkgs.librsvg pkgs.git ]; } @@ -181,6 +181,7 @@ hive-c0re = import ./nix/modules/hive-c0re.nix { hyperhivePackage = system: self.packages.${system}.default; hyperhiveFrontend = system: self.packages.${system}.frontend; + hyperhiveAssets = system: self.packages.${system}.assets; hyperhiveFlake = "${self}"; # Per-container toplevels — wired into `system.extraDependencies` # when `services.hive-c0re.preBuildAgentTemplates` is on so the diff --git a/hive-ag3nt/src/prompt.rs b/hive-ag3nt/src/prompt.rs index c82b343f..b737a92f 100644 --- a/hive-ag3nt/src/prompt.rs +++ b/hive-ag3nt/src/prompt.rs @@ -29,22 +29,24 @@ use std::path::{Path, PathBuf}; -use anyhow::Result; +use anyhow::{Context, Result}; use crate::mcp::Flavor; -const TEMPLATE: &str = include_str!("../prompts/system.md"); - /// Assemble the system prompt for a given flavor + label + pronouns. /// Pure function — no I/O. Splits out from [`write_system_prompt`] so /// the marker logic + substitution is unit-testable in isolation. +/// The caller supplies the template body so tests can pass an inline +/// fixture and production reads it once at harness startup via +/// [`hive_sh4re::assets::prompt_template`] (`$HIVE_ASSETS_DIR/prompts/ +/// system.md`). #[must_use] -pub fn render(flavor: Flavor, label: &str, operator_pronouns: &str) -> String { +pub fn render(template: &str, flavor: Flavor, label: &str, operator_pronouns: &str) -> String { let target = match flavor { Flavor::Agent => "agent", Flavor::Manager => "manager", }; - let body = filter_role_blocks(TEMPLATE, target); + let body = filter_role_blocks(template, target); body.replace("{label}", label) .replace("{operator_pronouns}", operator_pronouns) } @@ -123,7 +125,11 @@ pub async fn write_system_prompt( let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); tokio::fs::create_dir_all(parent).await.ok(); let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned()); - let body = render(flavor, label, &pronouns); + let template_path = hive_sh4re::assets::prompt_template(); + let template = tokio::fs::read_to_string(&template_path) + .await + .with_context(|| format!("read claude system prompt template from {}", template_path.display()))?; + let body = render(&template, flavor, label, &pronouns); let path = parent.join("claude-system-prompt.md"); tokio::fs::write(&path, body).await?; tracing::info!(path = %path.display(), "wrote claude system prompt"); @@ -134,6 +140,18 @@ pub async fn write_system_prompt( mod tests { use super::*; + // #555: production reads the system-prompt template from + // `$HIVE_ASSETS_DIR/prompts/system.md` at startup. The test module + // still `include_str!`s it directly because: + // 1. the "real template still substitutes / still filters" tests + // below need the actual production wording to be honest; + // 2. embedding it at compile time keeps `cargo test --workspace` + // runnable without setting `HIVE_ASSETS_DIR`; + // 3. this is the ONLY remaining compile-time reference to + // `prompts/system.md` from the rust workspace — production + // code loads it at runtime. + const PRODUCTION_TEMPLATE: &str = include_str!("../prompts/system.md"); + const SAMPLE: &str = "\ shared opener @@ -233,7 +251,7 @@ shared closer // Real template's first agent line — keeps the renderer // honest about the {label} / {operator_pronouns} pair the // harness already relied on. - let rendered = render(Flavor::Agent, "alice", "they/them"); + let rendered = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "they/them"); assert!(rendered.contains("hyperhive agent `alice`")); assert!(rendered.contains("**they/them** pronouns")); assert!(!rendered.contains("{label}")); @@ -246,7 +264,7 @@ shared closer // kill, schedule_*) MUST NOT appear in the agent's rendered // prompt. Drift between flavor and tool surface bites every // time it happens (cf. #511 missing-allow-list bug). - let rendered = render(Flavor::Agent, "alice", "she/her"); + let rendered = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her"); assert!(!rendered.contains("request_init_config")); assert!(!rendered.contains("request_apply_commit")); assert!(!rendered.contains("get_logs")); @@ -257,7 +275,7 @@ shared closer #[test] fn render_manager_includes_manager_only_tools() { - let rendered = render(Flavor::Manager, "hm1nd", "she/her"); + let rendered = render(PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); assert!(rendered.contains("request_init_config")); assert!(rendered.contains("request_apply_commit")); assert!(rendered.contains("get_logs")); @@ -269,9 +287,9 @@ shared closer #[test] fn render_uses_correct_role_opener() { - let agent = render(Flavor::Agent, "alice", "she/her"); + let agent = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her"); assert!(agent.starts_with("You are hyperhive agent")); - let manager = render(Flavor::Manager, "hm1nd", "she/her"); + let manager = render(PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); assert!(manager.starts_with("You are the hyperhive manager")); } } diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 28f56205..d6b88d70 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; @@ -18,15 +18,14 @@ use crate::events::{Bus, LiveEvent}; use crate::login::LoginState; use crate::mcp; -/// `--settings` JSON applied to every claude invocation. Lives as a -/// properly-formatted file in `prompts/claude-settings.json` so it's easy -/// to read and edit; we ship it via `include_str!`. We turn off claude's -/// in-session auto-compaction and its cross-session auto-memory because -/// hyperhive owns those concerns (`/compact` on overflow, notes -/// persistence under `/state`). Unknown keys are silently ignored by -/// claude-code; if a key gets renamed we'll spot it because the -/// corresponding behavior will start firing mid-turn again. -const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json"); +// `--settings` JSON is read at runtime from +// `$HIVE_ASSETS_DIR/prompts/claude-settings.json` via +// `hive_sh4re::assets::claude_settings()` (#555). We turn off claude's +// in-session auto-compaction and its cross-session auto-memory because +// hyperhive owns those concerns (`/compact` on overflow, notes +// persistence under `/state`). Unknown keys are silently ignored by +// claude-code; if a key gets renamed we'll spot it because the +// corresponding behavior will start firing mid-turn again. /// Regex-ish marker claude-code emits when context overflows. Same string /// bitburner-agent watches for. Empirically reliable across claude-code @@ -159,7 +158,14 @@ pub async fn write_settings(socket: &Path) -> Result { let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); tokio::fs::create_dir_all(parent).await.ok(); let path = parent.join("claude-settings.json"); - tokio::fs::write(&path, CLAUDE_SETTINGS).await?; + // #555: source-of-truth is `$HIVE_ASSETS_DIR/prompts/claude-settings.json`; + // copy through the per-agent runtime dir so claude reads it from the + // same socket-adjacent location every time and so a future override + // (per-agent settings JSON layer) drops in cleanly. + let src = hive_sh4re::assets::claude_settings(); + tokio::fs::copy(&src, &path) + .await + .with_context(|| format!("copy claude settings from {} to {}", src.display(), path.display()))?; tracing::info!(path = %path.display(), "wrote claude settings"); Ok(path) } @@ -508,10 +514,10 @@ pub async fn wait_for_login( /// regular files + newest `mtime` across them. The two axes are both /// load-bearing for `wait_for_login`'s refresh check (`session_refreshed`): /// mtime catches the common case (re-login overwrites an existing -/// credentials file in-place), file_count catches the pathological case +/// credentials file in-place), `file_count` catches the pathological case /// where `meta.modified()` errors on every file (exotic fs, NFS quirks) /// so the mtime axis stays `None` forever but new files still trigger a -/// resume. Defaults to `{0, None}` on read_dir failure (missing or +/// resume. Defaults to `{0, None}` on `read_dir` failure (missing or /// unreadable dir) — `wait_for_login` then resumes when files first /// appear. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -542,9 +548,9 @@ fn snapshot_dir(dir: &Path) -> DirSnapshot { /// Has the credentials dir been written since `prev`? Used as the /// exit condition for `wait_for_login`: /// -/// - file_count changed → something was added or removed, treat as +/// - `file_count` changed → something was added or removed, treat as /// refresh (covers the "all files have unreadable mtime" edge case). -/// - newest_mtime advanced → existing file was rewritten in place +/// - `newest_mtime` advanced → existing file was rewritten in place /// (the common claude re-login path). /// - prev had no mtime (empty or all-unreadable) and now has one → /// first useful signal we've seen, treat as refresh. diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index d53c5637..e44124f3 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -221,9 +221,15 @@ fn try_bind(addr: SocketAddr) -> std::io::Result { /// Always returns an image, so consumers (dashboard, favicon) can hit /// `/icon` unconditionally without probing whether one is configured. async fn serve_icon() -> impl IntoResponse { - const DEFAULT_ICON: &str = include_str!("../../branding/hyperhive.svg"); - let body = std::fs::read_to_string("/etc/hyperhive/icon.svg") - .unwrap_or_else(|_| DEFAULT_ICON.to_string()); + // Per-agent icon overrides go through `/etc/hyperhive/icon.svg` (set + // via the `hyperhive.icon` agent.nix option); the bundled default is + // resolved at runtime from + // `$HIVE_ASSETS_DIR/branding/hyperhive.svg` (#555). If neither file + // can be read we serve an empty body — keeps the response a valid + // SVG content-type without a panic on a misconfigured container. + let body = std::fs::read_to_string("/etc/hyperhive/icon.svg").unwrap_or_else(|_| { + std::fs::read_to_string(hive_sh4re::assets::branding_svg()).unwrap_or_default() + }); ([("content-type", "image/svg+xml")], body) } diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index a97e91c9..308d0c42 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -2,13 +2,6 @@ name = "hive-c0re" edition.workspace = true version.workspace = true -# Render branding/agent-configs.svg → $OUT_DIR/agent-configs.png at -# compile time (#424). build.rs shells out to `rsvg-convert` -# (librsvg, pulled in via flake.nix' crane nativeBuildInputs); the -# baked PNG is included via include_bytes! from forge.rs so no -# raster gets checked into git. -build = "build.rs" - [lints] workspace = true diff --git a/hive-c0re/build.rs b/hive-c0re/build.rs deleted file mode 100644 index c8bf3fc4..00000000 --- a/hive-c0re/build.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Render `branding/agent-configs.svg` → `$OUT_DIR/agent-configs.png` -//! at compile time so the daemon can `include_bytes!` the PNG without -//! checking the raster into git (#424 mara: "generate png on the fly -//! or in build"). The SVG is the source of truth; the PNG is a build -//! artifact. -//! -//! Uses `rsvg-convert` from PATH (librsvg, already available in -//! nixpkgs and added to the crane derivation's `nativeBuildInputs` -//! in `flake.nix`). For dev builds outside Nix, install librsvg via -//! your system package manager (Debian/Ubuntu: `librsvg2-bin`, -//! macOS: `brew install librsvg`). - -use std::env; -use std::path::PathBuf; -use std::process::Command; - -const SVG_PATH: &str = "../branding/agent-configs.svg"; -const PNG_NAME: &str = "agent-configs.png"; -// 300×300 to match the existing branding/hyperhive.png, which the -// Forgejo avatar endpoint accepts without resizing on upload. -const PX: &str = "300"; - -fn main() { - // Re-run the build script when either the SVG itself or this - // script change. We deliberately don't watch every file in - // `branding/` — only the one PNG we generate. - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed={SVG_PATH}"); - - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set by cargo")); - let png_path = out_dir.join(PNG_NAME); - - let status = Command::new("rsvg-convert") - .args(["--width", PX, "--height", PX, "-o"]) - .arg(&png_path) - .arg(SVG_PATH) - .status(); - - match status { - Ok(s) if s.success() => {} - Ok(s) => panic!("rsvg-convert exited with {s} rendering {SVG_PATH}"), - Err(e) => panic!( - "failed to invoke rsvg-convert: {e}\n\ - install librsvg (Debian/Ubuntu: librsvg2-bin, macOS: brew install librsvg, \ - NixOS: pkgs.librsvg). The Nix derivation already pulls it in via \ - flake.nix → craneLib.buildPackage.nativeBuildInputs.", - ), - } -} diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 7eb33c10..96760b87 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -41,20 +41,14 @@ const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set"; /// Sibling marker for the `agent-configs` org avatar (#424). Same one- /// shot semantics — delete to force the upload to re-run. const CONFIG_ORG_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-agent-configs-avatar-set"; -/// Hyperhive logo bytes, baked into the daemon. Uploaded once via the -/// admin avatar API so the `core` Forgejo user shows the project mark -/// next to commits in `agent-configs/*`, `core/meta`, etc. instead of -/// the default hash identicon. -const CORE_AVATAR_PNG: &[u8] = include_bytes!("../../branding/hyperhive.png"); -/// `agent-configs` org logo bytes (#424). Sibling visual to the main -/// hyperhive mark — same dark base + outer ring + corner brackets, -/// with a stacked-config-files glyph in the centre so the operator -/// can distinguish the agent-configs namespace from the main -/// `hyperhive` org at a glance. Source-of-truth is -/// `branding/agent-configs.svg`; `hive-c0re/build.rs` renders it -/// into `$OUT_DIR/agent-configs.png` at compile time via -/// `rsvg-convert` so the raster never gets checked into git. -const CONFIG_ORG_AVATAR_PNG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/agent-configs.png")); +// Avatar PNGs are loaded at runtime from +// `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the +// helpers in `hive_sh4re::assets` (#555 — was `include_bytes!` of an +// in-source path and an OUT_DIR-rendered sibling, both of which +// invalidated the crane src cache on any branding edit). The +// `agent-configs.png` is rendered from its SVG during the +// `hyperhive-assets` derivation's build (was `hive-c0re/build.rs` +// + `rsvg-convert` on PATH; both gone now). /// Forgejo org grouping every agent's applied config repo. Core is a /// site admin and reads + writes every repo here; agents are NOT /// members and the repos are private, so no agent — not even the one @@ -298,9 +292,13 @@ async fn ensure_core_avatar(token: &str) -> Result<()> { if marker.exists() { return Ok(()); } + let png_path = hive_sh4re::assets::core_avatar_png(); + let png_bytes = tokio::fs::read(&png_path) + .await + .with_context(|| format!("read core avatar PNG from {}", png_path.display()))?; let body = format!( r#"{{"image":"{}"}}"#, - base64::engine::general_purpose::STANDARD.encode(CORE_AVATAR_PNG), + base64::engine::general_purpose::STANDARD.encode(&png_bytes), ); let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar"); let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; @@ -325,9 +323,13 @@ async fn ensure_config_org_avatar(token: &str) -> Result<()> { if marker.exists() { return Ok(()); } + let png_path = hive_sh4re::assets::config_org_avatar_png(); + let png_bytes = tokio::fs::read(&png_path) + .await + .with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?; let body = format!( r#"{{"image":"{}"}}"#, - base64::engine::general_purpose::STANDARD.encode(CONFIG_ORG_AVATAR_PNG), + base64::engine::general_purpose::STANDARD.encode(&png_bytes), ); let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar"); let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; diff --git a/hive-sh4re/src/assets.rs b/hive-sh4re/src/assets.rs new file mode 100644 index 00000000..b5fcc0e1 --- /dev/null +++ b/hive-sh4re/src/assets.rs @@ -0,0 +1,99 @@ +//! Resolve the on-disk path to hyperhive's static assets (branding + +//! claude prompts). Single source of truth for both the host daemon +//! (`hive-c0re`) and the in-container harness (`hive-ag3nt` / +//! `hive-m1nd`) so they agree on the lookup contract. +//! +//! At runtime, the path is read from `$HIVE_ASSETS_DIR`. In nix +//! builds that env var is set by the `hive-c0re` / `harness-base` modules +//! to `${pkgs.hyperhive-assets}/share/hyperhive` (see `nix/assets.nix`). +//! For `cargo run` outside nix, set it yourself: +//! +//! ```sh +//! HIVE_ASSETS_DIR=$(pwd)/dev-assets cargo run ... +//! ``` +//! +//! where `dev-assets/` is laid out the same as the nix output: +//! +//! ```text +//! dev-assets/ +//! branding/ +//! hyperhive.{svg,png} +//! agent-configs.{svg,png} +//! prompts/ +//! system.md +//! claude-settings.json +//! ``` +//! +//! A convenience `cargo xtask seed-dev-assets` could materialise this +//! by copying `branding/` + `hive-ag3nt/prompts/` + rendering +//! `agent-configs.png` via rsvg-convert, but it's intentionally not +//! shipped yet — dev setups vary too much for one helper to fit. +//! +//! Each crate that wants a specific asset goes through one of the +//! typed helpers (`branding_svg()`, `prompt_template()`, …) so the +//! lookup contract is centralised. Missing files panic at first +//! call with a clear "set `HIVE_ASSETS_DIR` + put the file at …" +//! message — same failure shape as the old `include_*!` macros +//! (which would have failed at compile time, not runtime, but the +//! diagnostic value is identical). + +use std::path::PathBuf; + +/// Read `$HIVE_ASSETS_DIR`. Panics with a clear remediation message +/// when unset — every code path that calls into this module is one +/// the binary cannot run without, so a hard early failure is the +/// right shape. +fn dir() -> PathBuf { + match std::env::var("HIVE_ASSETS_DIR") { + Ok(v) if !v.is_empty() => PathBuf::from(v), + _ => panic!( + "HIVE_ASSETS_DIR is not set. Inside the nix-built systemd \ + units this is wired automatically from \ + `pkgs.hyperhive-assets`; for `cargo run` outside nix, \ + set it to a directory laid out like \ + `nix/assets.nix`'s output (branding/ + prompts/ \ + subdirs). See hive-sh4re/src/assets.rs for the contract.", + ), + } +} + +/// `$HIVE_ASSETS_DIR/branding/hyperhive.svg` — the project's primary +/// mark. Loaded by the per-agent web UI as its default icon +/// (`/agents//icon.svg` falls through here when the agent didn't +/// override `hyperhive.icon` in its `agent.nix`). +#[must_use] +pub fn branding_svg() -> PathBuf { + dir().join("branding/hyperhive.svg") +} + +/// `$HIVE_ASSETS_DIR/branding/hyperhive.png` — the same mark +/// rasterised, used by `hive-c0re::forge::push_avatar` to upload +/// the forge org avatar. +#[must_use] +pub fn core_avatar_png() -> PathBuf { + dir().join("branding/hyperhive.png") +} + +/// `$HIVE_ASSETS_DIR/branding/agent-configs.png` — secondary org +/// mark for the `agent-configs/` mirror org. Rendered from +/// `agent-configs.svg` at asset-build time (was rendered in +/// `hive-c0re/build.rs` before #555). +#[must_use] +pub fn config_org_avatar_png() -> PathBuf { + dir().join("branding/agent-configs.png") +} + +/// `$HIVE_ASSETS_DIR/prompts/system.md` — the claude system prompt +/// template. `hive-ag3nt::prompt::render` filters the role markers +/// inside it per agent / manager flavor. +#[must_use] +pub fn prompt_template() -> PathBuf { + dir().join("prompts/system.md") +} + +/// `$HIVE_ASSETS_DIR/prompts/claude-settings.json` — the static +/// `--settings` JSON every claude invocation reads. +#[must_use] +pub fn claude_settings() -> PathBuf { + dir().join("prompts/claude-settings.json") +} diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index c69ad976..000edcc9 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; +pub mod assets; + // ----------------------------------------------------------------------------- // Host admin socket — /run/hyperhive/host.sock // ----------------------------------------------------------------------------- diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 276f7983..20e2e1bd 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -1,6 +1,7 @@ { hyperhivePackage, hyperhiveFrontend, + hyperhiveAssets, hyperhiveFlake, agentBaseToplevel, managerToplevel, @@ -66,6 +67,20 @@ in endpoints) is the source of truth for any replacement. ''; }; + assets = lib.mkOption { + type = lib.types.package; + default = hyperhiveAssets pkgs.stdenv.hostPlatform.system; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.assets"; + description = '' + Bundled static runtime assets (see `./nix/assets.nix`): the + project's branding family + the claude system-prompt template + + claude-settings JSON. Output has `share/hyperhive/{branding,prompts}/`; + passed to hive-c0re's systemd unit via `HIVE_ASSETS_DIR` + (`hive_sh4re::assets::*` resolve paths underneath). Override to + ship customised branding or prompts without rebuilding the + rust derivation. + ''; + }; hyperhiveFlake = lib.mkOption { type = lib.types.str; default = hyperhiveFlake; @@ -196,6 +211,10 @@ in # serves this via `tower_http::ServeDir` for any path it doesn't # match against an API/action route. HIVE_STATIC_DIR = "${cfg.frontend}/dashboard"; + # Path to the static runtime asset tree (branding + claude + # prompts). `hive_sh4re::assets::*` reads paths underneath. + # `forge.rs` reads the avatar PNGs from here on startup. + HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive"; } // lib.optionalAttrs config.hyperhive.forge.enable { # Agents poll this URL for Forgejo notifications. Derived from diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 1ae8c3fa..f3c718d5 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -531,10 +531,15 @@ # HIVE_DEFAULT_MODEL seeds the initial model selection when no persisted # model choice exists in the state dir. SHELL must be set so claude's # Bash tool finds a POSIX shell. + # HIVE_ASSETS_DIR points at the project's static runtime assets + # (branding + claude prompts; see `nix/assets.nix`). Set here so + # both the harness binary and any user-shell `cargo run` inside the + # container resolve them from the same path. # HIVE_CONTEXT_WINDOW_TOKENS_* are injected by the meta flake from the # host-level `services.hive-c0re.contextWindowTokens` option — not set here. environment.variables = { HIVE_DEFAULT_MODEL = config.hyperhive.model; + HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; SHELL = "${pkgs.bashInteractive}/bin/bash"; } // lib.optionalAttrs (!config.hyperhive.autoCompact) { # Zero watermark disables proactive compaction; the reactive path From 0058ed1e59ebf9548d9786bc334796d6bdf858ec Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 29 May 2026 02:46:56 +0200 Subject: [PATCH 3/4] flake: narrow crane src to cleanCargoSource + prompts (closes #555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the asset-split in the previous commit the rust derivations have no compile-time dependency on `branding/*` and the only remaining reference to `hive-ag3nt/prompts/` is a `#[cfg(test)]` `include_str!` of `system.md` for the prompt-renderer tests. So we can finally narrow the src input down from `./.` (the post-naersk- port shape) to a fileset: fileset = lib.fileset.unions [ (craneLib.fileset.commonCargoSources ./.) # *.rs + Cargo.{toml,lock} ./hive-ag3nt/prompts # cfg(test) include_str! ]; Same `cleanSrc` is fed into all three derivations (`buildDepsOnly`, `buildPackage`, `cargoClippy`) so the input hash stays consistent across the chain (no surprise cache misses between stages of the same nix build). Verified the cache-invalidation contract by `echo '' >> ` and re-evaluating `.#default.outPath`: README.md → unchanged ✓ branding/hyperhive.{svg,png} → unchanged ✓ hive-c0re/src/main.rs → invalidates ✓ hive-ag3nt/prompts/system.md → invalidates ✓ (cfg(test)) branding/agent-configs.svg → unchanged ✓ (assets derivation rebuilds independently) End state: a tweak to nix modules, frontend JS, docs, README, or any branding asset rebuilds nothing rust-side. Only Rust source changes and prompt edits invalidate the cargo cache — and the prompt edit is gated to tests, so the production binary derivation is invariant to it (a follow-up could move the `include_str!` into its own test-only fixture if even that residual coupling matters, but the operator-visible cost today is zero). Closes #555. --- flake.nix | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/flake.nix b/flake.nix index 8aaa2491..f2f0bdca 100644 --- a/flake.nix +++ b/flake.nix @@ -46,16 +46,33 @@ pkgs = nixpkgs.legacyPackages.${system}; treefmt-eval = treefmt-nix.lib.evalModule pkgs treefmt-config; craneLib = crane.mkLib pkgs; + # Narrowed source tree the rust derivations consume. + # `commonCargoSources` is crane's standard "everything cargo + # cares about" filter (Cargo.toml/Cargo.lock + *.rs); we + # union it with the one non-rust path the workspace still + # references — `hive-ag3nt/prompts/` — which is read at + # compile time by `hive-ag3nt::prompt::tests` via + # `include_str!`. Branding assets + claude prompts at + # runtime live in the `hyperhive-assets` derivation + # (#555), so a tweak to e.g. `branding/hyperhive.svg`, + # `README.md`, the `nix/` modules, or the frontend tree + # does NOT bust this src hash and the rust derivations + # stay cached. + cleanSrc = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + (craneLib.fileset.commonCargoSources ./.) + ./hive-ag3nt/prompts + ]; + }; # Build the workspace's dependency tree once, cached as # its own derivation. `buildPackage` and `cargoClippy` # both reuse this via `inherit cargoArtifacts;` so a - # workspace-only edit doesn't rebuild deps. Same - # `nativeBuildInputs` as the workspace build itself — - # build.rs runs during dep-build too (any deps with a - # build.rs need rsvg too if they transitively pull it - # in; harmless if they don't). + # workspace-only edit doesn't rebuild deps. All three + # derivations consume the same `cleanSrc` so the input + # hash stays consistent across the chain. cargoArtifacts = craneLib.buildDepsOnly { - src = ./.; + src = cleanSrc; # Workspace Cargo.toml is virtual (no `[package].name`), # so crane can't auto-derive a name. Spell it out # explicitly here and below — keeps the derivation name @@ -88,13 +105,14 @@ { pkgs, craneLib, + cleanSrc, cargoArtifacts, nativeBuildInputs, ... }: { default = craneLib.buildPackage { - src = ./.; + src = cleanSrc; inherit cargoArtifacts nativeBuildInputs; pname = "hyperhive-workspace"; version = "0.1.0"; @@ -250,6 +268,7 @@ { treefmt-eval, craneLib, + cleanSrc, cargoArtifacts, nativeBuildInputs, ... @@ -265,7 +284,7 @@ # separator, which is why the old wiring went through # overrideAttrs). clippy = craneLib.cargoClippy { - src = ./.; + src = cleanSrc; inherit cargoArtifacts nativeBuildInputs; pname = "hyperhive-workspace"; version = "0.1.0"; From b3f675e367528c5e69ecb58b9d4548750bfbe017 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 29 May 2026 10:55:55 +0200 Subject: [PATCH 4/4] flake: lift cargo test out of buildPackage so prompt edits don't bust the binary cache (#555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per mara's review on PR #561: the previous commit kept `./hive-ag3nt/prompts` in `cleanSrc` because `hive-ag3nt::prompt::tests` had a compile-time `include_str!("../prompts/system.md")`. That meant a prompt edit still busted the cargo cache. This change: - Replaces the test-side `include_str!` with a runtime read from `$HIVE_ASSETS_DIR/prompts/system.md` (with a CARGO_MANIFEST_DIR fallback for plain `cargo test` from a checked-out repo). - Drops `./hive-ag3nt/prompts` from `cleanSrc` — it's now `craneLib.cleanCargoSource ./.` (Cargo.* + *.rs only). - Sets `doCheck = false` on `packages.default` and lifts `cargo test` into a separate `checks.cargo-test` derivation that carries the `hyperhive-assets` build input. That scopes the asset rebuild blast radius to the test check — `nix flake check` still exercises the suite, but the binary derivation no longer carries the assets dep. Verified cache-invariance matrix (via `echo '' >> ; nix eval .#default.outPath`): | edit | default | cargo-test | clippy | |-------------------------|---------|------------|--------| | README.md | stable | stable | stable | | branding/hyperhive.svg | stable | CHANGED | stable | | nix/modules/* | stable | stable | stable | | prompts/system.md | stable | CHANGED | stable | | hive-c0re/src/main.rs | CHANGED | CHANGED | CHANGED | (`cargo-test` CHANGED on prompts/branding is correct — tests read the production template + need the assets output.) --- flake.nix | 72 +++++++++++++++++++++++++++++----------- hive-ag3nt/src/prompt.rs | 63 +++++++++++++++++++++++------------ 2 files changed, 93 insertions(+), 42 deletions(-) diff --git a/flake.nix b/flake.nix index f2f0bdca..f6d43a86 100644 --- a/flake.nix +++ b/flake.nix @@ -47,24 +47,23 @@ treefmt-eval = treefmt-nix.lib.evalModule pkgs treefmt-config; craneLib = crane.mkLib pkgs; # Narrowed source tree the rust derivations consume. - # `commonCargoSources` is crane's standard "everything cargo - # cares about" filter (Cargo.toml/Cargo.lock + *.rs); we - # union it with the one non-rust path the workspace still - # references — `hive-ag3nt/prompts/` — which is read at - # compile time by `hive-ag3nt::prompt::tests` via - # `include_str!`. Branding assets + claude prompts at - # runtime live in the `hyperhive-assets` derivation - # (#555), so a tweak to e.g. `branding/hyperhive.svg`, - # `README.md`, the `nix/` modules, or the frontend tree - # does NOT bust this src hash and the rust derivations - # stay cached. - cleanSrc = lib.fileset.toSource { - root = ./.; - fileset = lib.fileset.unions [ - (craneLib.fileset.commonCargoSources ./.) - ./hive-ag3nt/prompts - ]; - }; + # `cleanCargoSource` is crane's standard "everything cargo + # cares about" filter (Cargo.toml/Cargo.lock + *.rs). All + # non-rust runtime assets — branding + the claude system + # prompt template + claude-settings.json — live in the + # separate `hyperhive-assets` derivation (#555) and are + # loaded by the binaries at runtime from `$HIVE_ASSETS_DIR`. + # The unit tests in `hive-ag3nt::prompt` read the same + # `prompts/system.md` directly from the workspace tree at + # *test* runtime (via `env!("CARGO_MANIFEST_DIR")` — a + # compile-time string, no file open at compile), so the + # prompt template doesn't have to be in this fileset to + # keep `cargo test` honest. Net effect: tweaks to any + # non-`*.rs` / non-`Cargo.*` file (README, branding, + # nix modules, frontend tree, OR `hive-ag3nt/prompts/*`) + # do NOT bust this src hash, so the rust derivations + # stay fully cached. + cleanSrc = craneLib.cleanCargoSource ./.; # Build the workspace's dependency tree once, cached as # its own derivation. `buildPackage` and `cargoClippy` # both reuse this via `inherit cargoArtifacts;` so a @@ -111,12 +110,24 @@ ... }: { + # Build the workspace binaries without running tests. Tests + # are run as a separate check (`checks.cargo-test`) that + # carries the `hyperhive-assets` build input — `hive-ag3nt:: + # prompt::tests` reads the production prompt template at test + # runtime through `$HIVE_ASSETS_DIR`, so wiring the env var + # into the build phase here would make the prompt's hash a + # build input of `default` (defeats #555's cache goal: a + # prompt edit would still bust the binary derivation, even + # though no .rs file changed). Keeping tests in a separate + # check derivation localises the asset-rebuild blast radius + # to that one check — `nix flake check` still exercises them. default = craneLib.buildPackage { src = cleanSrc; inherit cargoArtifacts nativeBuildInputs; pname = "hyperhive-workspace"; version = "0.1.0"; meta.description = "hyperhive workspace (hive-c0re, hive-ag3nt, hive-m1nd)"; + doCheck = false; }; # Bundled browser assets — see ./nix/frontend.nix. Output is # $out/{dashboard,agent}/ which the Rust binaries serve via @@ -125,10 +136,12 @@ branding-svg = ./branding/hyperhive.svg; }; # Static runtime assets the rust binaries read via - # `assets::path()` (#555): branding/* + hive-ag3nt/prompts/*, + # `hive_sh4re::assets::*` (#555): branding/* + prompts/*, # plus the rendered agent-configs.png. Split out of the # rust derivation so a tweak to e.g. system.md doesn't bust - # the cargo cache. + # the cargo cache. Build input of the `cargo-test` check but + # NOT of `packages.default`, so the binary derivation stays + # cached when a prompt edit ripples through. assets = pkgs.callPackage ./nix/assets.nix { }; # Pre-built per-container system closures. Exposed as packages # so operators can `nix build .#agent-base-toplevel` (or wire @@ -266,6 +279,8 @@ checks = forAllSystems ( { + pkgs, + system, treefmt-eval, craneLib, cleanSrc, @@ -290,6 +305,23 @@ version = "0.1.0"; cargoClippyExtraArgs = "--workspace --all-targets -- -D warnings"; }; + # `cargo test --workspace` lifted out of `buildPackage` so the + # `hyperhive-assets` dep (which `hive-ag3nt::prompt::tests` + # needs via `HIVE_ASSETS_DIR` to assert against the actual + # production prompt template) is scoped to this one check + # instead of bleeding into the binary derivation's input + # hash. Net: editing `hive-ag3nt/prompts/system.md` still + # rebuilds this test check (correct — the tests assert + # against its wording), but `packages.default` and the + # per-container toplevels stay fully cached. + cargo-test = craneLib.cargoTest { + src = cleanSrc; + inherit cargoArtifacts nativeBuildInputs; + pname = "hyperhive-workspace"; + version = "0.1.0"; + cargoTestExtraArgs = "--workspace"; + HIVE_ASSETS_DIR = "${self.packages.${system}.assets}/share/hyperhive"; + }; } ); }; diff --git a/hive-ag3nt/src/prompt.rs b/hive-ag3nt/src/prompt.rs index b737a92f..50bac2b8 100644 --- a/hive-ag3nt/src/prompt.rs +++ b/hive-ag3nt/src/prompt.rs @@ -117,18 +117,19 @@ fn parse_close_marker(line: &str) -> Option<&str> { /// # Errors /// /// Returns an error if the system prompt file cannot be written. -pub async fn write_system_prompt( - socket: &Path, - label: &str, - flavor: Flavor, -) -> Result { +pub async fn write_system_prompt(socket: &Path, label: &str, flavor: Flavor) -> Result { let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); tokio::fs::create_dir_all(parent).await.ok(); let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned()); let template_path = hive_sh4re::assets::prompt_template(); let template = tokio::fs::read_to_string(&template_path) .await - .with_context(|| format!("read claude system prompt template from {}", template_path.display()))?; + .with_context(|| { + format!( + "read claude system prompt template from {}", + template_path.display() + ) + })?; let body = render(&template, flavor, label, &pronouns); let path = parent.join("claude-system-prompt.md"); tokio::fs::write(&path, body).await?; @@ -139,18 +140,36 @@ pub async fn write_system_prompt( #[cfg(test)] mod tests { use super::*; + use std::sync::LazyLock; - // #555: production reads the system-prompt template from - // `$HIVE_ASSETS_DIR/prompts/system.md` at startup. The test module - // still `include_str!`s it directly because: - // 1. the "real template still substitutes / still filters" tests - // below need the actual production wording to be honest; - // 2. embedding it at compile time keeps `cargo test --workspace` - // runnable without setting `HIVE_ASSETS_DIR`; - // 3. this is the ONLY remaining compile-time reference to - // `prompts/system.md` from the rust workspace — production - // code loads it at runtime. - const PRODUCTION_TEMPLATE: &str = include_str!("../prompts/system.md"); + // #555: the production template lives at + // `$HIVE_ASSETS_DIR/prompts/system.md` and is loaded at runtime. + // The unit tests below want to assert against the actual production + // wording (so the renderer + tool surface stay honest), so they + // resolve the same path at test runtime via two fallbacks: + // 1. `$HIVE_ASSETS_DIR/prompts/system.md` — the runtime contract + // production uses. The flake's `checks.cargo-test` derivation + // sets this to the `hyperhive-assets` output so `cargo test` + // inside the nix sandbox finds the file without needing + // `prompts/` in the cargo source tree. `packages.default` + // explicitly does NOT carry the assets dep, so a prompt edit + // doesn't bust the binary derivation — only this test check. + // 2. `env!("CARGO_MANIFEST_DIR")/prompts/system.md` — for plain + // `cargo test --workspace` from a checked-out repo where the + // env var isn't set; `env!` is a compile-time string lookup, + // no file open at compile, so this still doesn't pull + // `prompts/` into the build hash. + // The combined effect is that the flake's `cleanSrc` no longer + // unions `./hive-ag3nt/prompts` — tweaks to system.md don't bust + // the cargo cache anymore. + static PRODUCTION_TEMPLATE: LazyLock = LazyLock::new(|| { + let path = match std::env::var("HIVE_ASSETS_DIR") { + Ok(v) if !v.is_empty() => format!("{v}/prompts/system.md"), + _ => concat!(env!("CARGO_MANIFEST_DIR"), "/prompts/system.md").to_owned(), + }; + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read production prompt template at {path}: {e}")) + }); const SAMPLE: &str = "\ shared opener @@ -251,7 +270,7 @@ shared closer // Real template's first agent line — keeps the renderer // honest about the {label} / {operator_pronouns} pair the // harness already relied on. - let rendered = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "they/them"); + let rendered = render(&PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "they/them"); assert!(rendered.contains("hyperhive agent `alice`")); assert!(rendered.contains("**they/them** pronouns")); assert!(!rendered.contains("{label}")); @@ -264,7 +283,7 @@ shared closer // kill, schedule_*) MUST NOT appear in the agent's rendered // prompt. Drift between flavor and tool surface bites every // time it happens (cf. #511 missing-allow-list bug). - let rendered = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her"); + let rendered = render(&PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her"); assert!(!rendered.contains("request_init_config")); assert!(!rendered.contains("request_apply_commit")); assert!(!rendered.contains("get_logs")); @@ -275,7 +294,7 @@ shared closer #[test] fn render_manager_includes_manager_only_tools() { - let rendered = render(PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); + let rendered = render(&PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); assert!(rendered.contains("request_init_config")); assert!(rendered.contains("request_apply_commit")); assert!(rendered.contains("get_logs")); @@ -287,9 +306,9 @@ shared closer #[test] fn render_uses_correct_role_opener() { - let agent = render(PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her"); + let agent = render(&PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her"); assert!(agent.starts_with("You are hyperhive agent")); - let manager = render(PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); + let manager = render(&PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); assert!(manager.starts_with("You are the hyperhive manager")); } }