forge: auto-set agent-configs org avatar on core start (#424)

Sibling to ensure_core_avatar (#320). Same one-shot marker-guarded
upload pattern, this time aimed at the Forgejo per-org avatar
endpoint (`POST /api/v1/orgs/{org}/avatar`).

## SVG-to-PNG at build time

Mara: *don't check in the png. instead generate png on the fly or
in build.*

`hive-c0re/build.rs` renders `branding/agent-configs.svg` →
`$OUT_DIR/agent-configs.png` via `rsvg-convert` (librsvg) on every
compile; `forge.rs` then `include_bytes!`s the OUT_DIR PNG. The
raster never gets checked into git — SVG stays source of truth,
the PNG is a build artifact.

- `hive-c0re/Cargo.toml`: declares `build = "build.rs"`
- `flake.nix`: adds `librsvg` to `naersk-lib.buildPackage`
  `nativeBuildInputs` (covers both the runtime package and the
  clippy check derivation) and to the dev shell so local
  `cargo build` finds `rsvg-convert` on PATH.
- For dev builds outside Nix, install librsvg (Debian:
  `librsvg2-bin`, macOS: `brew install librsvg`).

## Icon design

Sibling visual to the main hyperhive mark — same dark base + outer
ring + corner-bracket frame so the family reads at a glance. Centre
swaps the hexagonal hive for a stacked-config-files motif: three
offset sheets, folded-corner affordance, curly-brace `{ }` glyph
telegraphing "config file."

Brace font-size dropped 78→56 + letter-spacing -3 (#424 mara:
"braces cross the boundaries of the page") so the glyphs sit
comfortably inside the 120-wide front sheet with clear breathing
room on the left/right edges. The stack is shifted so the front
sheet centres on canvas-(150, 150); brace text anchors there with
`dominant-baseline=central` for true vertical centring.

## Validation

- `cargo check` clean (only pre-existing warnings).
- One-shot marker honoured: re-runs of `ensure_all` skip after the
  first success; `rm /var/lib/hyperhive/forge-agent-configs-avatar-set`
  forces re-upload (useful for icon revisions).
- Behaviour mirrors the existing `ensure_core_avatar` pattern.

Browser smoke test isn't possible from inside iris's container.
Worth eyeballing post-deploy: `http://localhost:3000/agent-configs`
should show the new avatar where the default identicon used to be.
This commit is contained in:
iris 2026-05-25 23:09:00 +02:00 committed by Mara
commit a444774ab3
5 changed files with 225 additions and 0 deletions

View file

@ -2,6 +2,12 @@
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' naersk 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

49
hive-c0re/build.rs Normal file
View file

@ -0,0 +1,49 @@
//! 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 naersk 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 naersk-lib.buildPackage.nativeBuildInputs.",
),
}
}

View file

@ -38,11 +38,23 @@ const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
/// the upload runs once, the marker is written, subsequent startups skip
/// the call. Delete to force re-upload.
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"));
/// 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
@ -303,6 +315,33 @@ async fn ensure_core_avatar(token: &str) -> Result<()> {
Ok(())
}
/// Set the `agent-configs` org's Forgejo avatar to the
/// configs-stack glyph once (#424). Sibling to `ensure_core_avatar`:
/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar
/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG
/// JSON body — same shape as the admin user endpoint above.
async fn ensure_config_org_avatar(token: &str) -> Result<()> {
let marker = std::path::Path::new(CONFIG_ORG_AVATAR_MARKER);
if marker.exists() {
return Ok(());
}
let body = format!(
r#"{{"image":"{}"}}"#,
base64::engine::general_purpose::STANDARD.encode(CONFIG_ORG_AVATAR_PNG),
);
let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar");
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
if !status.is_success() {
anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}");
}
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(marker, "").ok();
tracing::info!(org = CONFIG_ORG, "forge: set org avatar to configs-stack logo");
Ok(())
}
/// Ensure the bootstrap `core` admin user + a token at
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
/// API calls (org creation now, meta-repo push later). Returns the
@ -631,6 +670,9 @@ pub async fn ensure_all() {
if let Err(e) = ensure_core_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
}
if let Err(e) = ensure_config_org_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_config_org_avatar failed");
}
}
let Ok(containers) = crate::lifecycle::list().await else {
tracing::warn!("forge: nixos-container list failed; skipping user sweep");