diff --git a/branding/agent-default.svg b/branding/agent-default.svg new file mode 100644 index 000000000..af241cf54 --- /dev/null +++ b/branding/agent-default.svg @@ -0,0 +1,24 @@ + + Agent + The default agent icon: a robot head in line art, in a round frame + + + + + + + + + + + + + + + + + + + + + diff --git a/branding/service-builder.svg b/branding/service-builder.svg new file mode 100644 index 000000000..4356f86d9 --- /dev/null +++ b/branding/service-builder.svg @@ -0,0 +1,22 @@ + + builder + The swarm builder service: a hammer over a cube, in a hexagonal frame + + + + + + + + + + + + + + + + + + + diff --git a/branding/service-ci-metrics.svg b/branding/service-ci-metrics.svg new file mode 100644 index 000000000..46615328c --- /dev/null +++ b/branding/service-ci-metrics.svg @@ -0,0 +1,23 @@ + + ci-metrics + The CI metrics service: a bar chart with a trend line, in a hexagonal frame + + + + + + + + + + + + + + + + + + + + diff --git a/branding/service-review-classifier.svg b/branding/service-review-classifier.svg new file mode 100644 index 000000000..16e9e2195 --- /dev/null +++ b/branding/service-review-classifier.svg @@ -0,0 +1,23 @@ + + review-classifier + The review classifier service: a pair of scales, in a hexagonal frame + + + + + + + + + + + + + + + + + + + + diff --git a/docs/4-implementation/swarm/controller.md b/docs/4-implementation/swarm/controller.md index c729b694a..75b099c95 100644 --- a/docs/4-implementation/swarm/controller.md +++ b/docs/4-implementation/swarm/controller.md @@ -258,8 +258,8 @@ once per start, because a config change reaches an agent by restarting its container. An agent with no icon deletes its key. Swarm-side, `GET /api/agents//icon` serves the stored bytes, and 404 means the agent has no icon. swarm-ui's agent cards -load it as an `` and show the dimmed hyperhive mark for an agent without -one, as the hive dashboard does. +load it as an `` and show the dimmed default agent icon (a robot head in a +round frame, `branding/agent-default.svg`) for an agent without one. The fourth is its **`AGENTS.md`**, which goes into the `agents-md` bucket under the key ``, writable by the agent's own credential alone @@ -363,6 +363,17 @@ one per hive. It ensures them at start and every five minutes after - the `agent-configs` org avatar (`deploy.swarm-controller.configOrgAvatarPng`). +The swarm's own forge accounts (`builder`, `ci-metrics` and `review-classifier`) +get an avatar too, from a separate pass in the controller (`forge/service_avatars.rs`). +Each has an icon in a hexagonal frame (`branding/service-.svg`, rendered to a PNG +in the assets package) and falls back to the hyperhive logo. Only an account can set its own +avatar, so the pass mints a short-lived `write:user` token for it with the admin API, uploads +the image, and deletes the token. A marker per account in the state directory +(`forge-service-avatar-`) holds the image's SHA-256, so a changed icon uploads again +and an unchanged one doesn't. The directory with the icons is +`SWARM_CONTROLLER_SERVICE_ICON_DIR`, set by the nix module. An account that doesn't exist yet +gets passed over and shows up on the next pass, every five minutes. + A pass that can't finish logs a `warn` line per object plus `swarm forge objects: pass incomplete` in `journalctl -u swarm-controller`, and retries on the next tick. While the diff --git a/docs/4-implementation/web-ui/swarm-ui.md b/docs/4-implementation/web-ui/swarm-ui.md index 8bfd47480..c7452a2c4 100644 --- a/docs/4-implementation/web-ui/swarm-ui.md +++ b/docs/4-implementation/web-ui/swarm-ui.md @@ -178,7 +178,7 @@ you flip it follow it. A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of different agents tell apart. A subagent's tab shows its agent's icon. An agent -with no icon keeps the hyperhive mark. +with no icon keeps the default agent icon. The tab also has an agent badge, with the agent's icon and name, ahead of the connection badge. It opens the agent's details in a popover under the badge: diff --git a/frontend/packages/swarm-ui/src/agent/AgentIcon.css b/frontend/packages/swarm-ui/src/agent/AgentIcon.css index de4110c52..67baafb5d 100644 --- a/frontend/packages/swarm-ui/src/agent/AgentIcon.css +++ b/frontend/packages/swarm-ui/src/agent/AgentIcon.css @@ -20,6 +20,10 @@ filter: grayscale(1); opacity: 0.4; } +/* The default agent icon: dimmed, but in its colour. */ +.ui-agent-icon-default { + opacity: 0.6; +} /* Beside a line of text, inside a badge. */ .ui-agent-icon-small { width: 1.3em; diff --git a/frontend/packages/swarm-ui/src/agent/AgentIcon.tsx b/frontend/packages/swarm-ui/src/agent/AgentIcon.tsx index 2fbf67eda..69efd0d50 100644 --- a/frontend/packages/swarm-ui/src/agent/AgentIcon.tsx +++ b/frontend/packages/swarm-ui/src/agent/AgentIcon.tsx @@ -1,7 +1,8 @@ // — the agent's icon from swarm-controller's -// `GET /api/agents/{name}/icon`, the same square and fallback as the hive -// dashboard's container row: an agent with no icon (404) or an unreachable -// controller shows the dimmed hyperhive mark instead of a broken image. +// `GET /api/agents/{name}/icon`: an agent with no icon (404) or an unreachable +// controller shows the default agent icon, dimmed, instead of a broken image. +// It is a robot head in a round frame, so it is not mistaken for a swarm +// service, whose icons sit in a hexagon. // Always an ``: the body is an agent-authored SVG, and only an image load // keeps its script from running. import { useState } from "preact/hooks"; @@ -21,14 +22,14 @@ export function AgentIcon({ class={ "ui-agent-icon" + (small ? " ui-agent-icon-small" : "") + - (fallback ? " ui-agent-icon-none" : "") + (fallback ? " ui-agent-icon-default" : "") } > setFallback(true)} diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 20b0c4063..10faf96d7 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -210,6 +210,8 @@ let "${deployCfg.swarm-controller.configOrgAvatarPng}" else "${config.services.hyperhive.deploy.hive-controller.assets}/share/hyperhive/branding/agent-configs.png"; + # The swarm services' own forge accounts' avatars (`forge/service_avatars.rs`). + SWARM_CONTROLLER_SERVICE_ICON_DIR = "${config.services.hyperhive.deploy.hive-controller.assets}/share/hyperhive/branding"; }; # How the forge must address this controller to deliver a swarm-wide diff --git a/nix/packages/assets.nix b/nix/packages/assets.nix index bafd407c6..2e87e4e73 100644 --- a/nix/packages/assets.nix +++ b/nix/packages/assets.nix @@ -10,6 +10,7 @@ # # Output layout: # $out/share/hyperhive/branding/{hyperhive,agent-configs}.{svg,png} +# $out/share/hyperhive/branding/service-*.{svg,png} (the swarm services' forge avatars) # $out/share/hyperhive/prompts/{system.md, claude-settings.json} # # The repo docs/ tree is a SEPARATE derivation (./reference-docs.nix) @@ -42,6 +43,9 @@ stdenv.mkDerivation { rsvg-convert --width 300 --height 300 \ -o branding/agent-configs.png \ branding/agent-configs.svg + for svg in branding/service-*.svg; do + rsvg-convert --width 300 --height 300 -o "''${svg%.svg}.png" "$svg" + done runHook postBuild ''; diff --git a/nix/packages/default.nix b/nix/packages/default.nix index 929a7f817..19fb65e4e 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -228,6 +228,7 @@ in swarm-ui = pkgs.callPackage ./swarm-ui.nix { branding-svg = ../../branding/hyperhive.svg; branding-svg-maskable = ../../branding/hyperhive-maskable.svg; + agent-default-svg = ../../branding/agent-default.svg; }; # Bundled browser assets — see ./frontend.nix. Output is diff --git a/nix/packages/swarm-ui.nix b/nix/packages/swarm-ui.nix index 6d1e5d523..371fe9dd5 100644 --- a/nix/packages/swarm-ui.nix +++ b/nix/packages/swarm-ui.nix @@ -2,6 +2,7 @@ buildNpmPackage, branding-svg, branding-svg-maskable, + agent-default-svg, librsvg, }: @@ -66,6 +67,8 @@ buildNpmPackage { mkdir -p $out cp -r packages/swarm-ui/dist/. $out/ cp ${branding-svg} $out/favicon.svg + # What an agent with no icon of its own shows (`AgentIcon.tsx`). + cp ${agent-default-svg} $out/agent-default.svg rsvg-convert -w 192 -h 192 ${branding-svg} -o $out/icon-192.png rsvg-convert -w 512 -h 512 ${branding-svg} -o $out/icon-512.png diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index 41ee0130d..b71756257 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -61,6 +61,7 @@ pub mod pr_report; pub mod pull_diff; pub mod review_request; pub mod runner_token; +pub mod service_avatars; pub mod site_admin; /// The longest commit status description [`Client::create_commit_status`] diff --git a/swarm-controller/src/forge/service_avatars.rs b/swarm-controller/src/forge/service_avatars.rs new file mode 100644 index 000000000..754dd7da5 --- /dev/null +++ b/swarm-controller/src/forge/service_avatars.rs @@ -0,0 +1,261 @@ +//! The avatars of the swarm's own forge accounts: [`builder_account::LOGIN`], +//! [`ci_metrics_account::LOGIN`] and [`classifier_account::LOGIN`], which the +//! controller creates itself. Without one the forge shows an identicon. +//! +//! Each gets `service-.png` from [`ICON_DIR_ENV`]'s directory, the +//! hyperhive logo (`hyperhive.png`) when that file is missing. The images are +//! the `branding/` SVGs rendered by `nix/packages/assets.nix`. +//! +//! **Only the account itself may set its avatar**, so each pass mints a +//! short-lived token for it with the admin API, scoped to `write:user` and +//! nothing else, sets the avatar, and deletes the token again. The accounts' +//! own tokens never carry that scope. +//! +//! A marker per account under the state directory records the fingerprint of +//! the image last uploaded, so a pass only uploads when the image changed. An +//! account that does not exist yet is skipped; the next pass finds it. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use base64::Engine as _; +use forgejo_api::structs::{CreateAccessTokenOption, UpdateUserAvatarOption}; +use forgejo_api::{Auth, Forgejo}; +use sha2::{Digest as _, Sha256}; + +use super::agent_token::is_not_found; +use super::{Client, builder_account, ci_metrics_account, classifier_account}; + +/// The directory holding the rendered icons, set by `swarm-controller.nix`. +const ICON_DIR_ENV: &str = "SWARM_CONTROLLER_SERVICE_ICON_DIR"; + +/// The accounts that get an avatar. +const SERVICES: [&str; 3] = [ + builder_account::LOGIN, + ci_metrics_account::LOGIN, + classifier_account::LOGIN, +]; + +/// What a service without an icon of its own shows. +const FALLBACK_ICON: &str = "hyperhive.png"; + +/// The name of the short-lived token. +const TOKEN_NAME: &str = "swarm-avatar"; + +/// What the short-lived token may do: edit the account's own profile. +const TOKEN_SCOPES: [&str; 1] = ["write:user"]; + +/// How often the pass re-runs. Avatars rarely change; this is about how long +/// an account created after the first pass waits for its avatar. +const INTERVAL: Duration = Duration::from_mins(5); + +/// The file `login`'s icon is read from. +fn icon_file(login: &str) -> String { + format!("service-{login}.png") +} + +/// The image to upload for `login`: its own icon, else the fallback. +fn pick_icon(dir: &Path, login: &str) -> PathBuf { + let own = dir.join(icon_file(login)); + if own.is_file() { + own + } else { + dir.join(FALLBACK_ICON) + } +} + +/// What the marker holds: the SHA-256 of the image, in hex. +fn fingerprint(image: &[u8]) -> String { + use std::fmt::Write as _; + Sha256::digest(image) + .iter() + .fold(String::new(), |mut hex, b| { + let _ = write!(hex, "{b:02x}"); + hex + }) +} + +/// Where `login`'s marker lives. +fn marker(state_dir: &Path, login: &str) -> PathBuf { + state_dir.join(format!("forge-service-avatar-{login}")) +} + +impl Client { + /// Set the avatar of every service account whose image changed since the + /// last upload. Returns how many were set. + /// + /// # Errors + /// The first account's failure, after every account has been tried. + pub async fn ensure_service_avatars(&self, icon_dir: &Path, state_dir: &Path) -> Result { + let mut set = 0; + let mut first_error = None; + for login in SERVICES { + match self.ensure_service_avatar(login, icon_dir, state_dir).await { + Ok(true) => set += 1, + Ok(false) => {} + Err(e) => { + tracing::warn!(%login, error = %format!("{e:#}"), "service avatars: setting the avatar failed"); + first_error.get_or_insert(e); + } + } + } + match first_error { + Some(e) => Err(e), + None => Ok(set), + } + } + + /// One account. `Ok(true)` when the avatar was set, `Ok(false)` when it is + /// current or the account does not exist yet. + async fn ensure_service_avatar( + &self, + login: &str, + icon_dir: &Path, + state_dir: &Path, + ) -> Result { + let path = pick_icon(icon_dir, login); + let image = tokio::fs::read(&path) + .await + .with_context(|| format!("reading {}", path.display()))?; + let print = fingerprint(&image); + let marker = marker(state_dir, login); + if std::fs::read_to_string(&marker).is_ok_and(|seen| seen.trim() == print) { + return Ok(false); + } + if !self.set_user_avatar(login, &image).await? { + tracing::debug!(%login, "service avatars: the account does not exist yet"); + return Ok(false); + } + if let Err(e) = std::fs::write(&marker, &print) { + tracing::warn!(error = %e, marker = %marker.display(), "service avatars: avatar set, but its marker could not be written"); + } + tracing::info!(%login, "service avatars: set the avatar"); + Ok(true) + } + + /// Set `login`'s avatar to `image` as the account itself, with a token that + /// exists only for this call. `Ok(false)` when the account does not exist. + async fn set_user_avatar(&self, login: &str, image: &[u8]) -> Result { + match self + .api + .call(|api| { + api.admin_delete_user_access_token(login, TOKEN_NAME) + .into_future() + }) + .await + { + Ok(()) => {} + Err(e) if is_not_found(&e) => {} + Err(e) => { + return Err(e).with_context(|| format!("clearing {login}'s {TOKEN_NAME} token")); + } + } + let created = match self + .api + .call(|api| { + api.admin_create_user_access_token( + login, + CreateAccessTokenOption { + name: TOKEN_NAME.to_owned(), + repositories: None, + scopes: Some(TOKEN_SCOPES.iter().map(|&s| s.to_owned()).collect()), + }, + ) + .into_future() + }) + .await + { + Ok(created) => created, + Err(e) if is_not_found(&e) => return Ok(false), + Err(e) => { + return Err(e).with_context(|| format!("creating {login}'s {TOKEN_NAME} token")); + } + }; + let token = created + .sha1 + .filter(|v| !v.is_empty()) + .with_context(|| format!("the forge created {login}'s token but returned no value"))?; + let uploaded = Forgejo::new(Auth::Token(&token), self.url.clone()) + .context("building a forge client with the avatar token")? + .user_update_avatar(UpdateUserAvatarOption { + image: Some(base64::engine::general_purpose::STANDARD.encode(image)), + }) + .await + .with_context(|| format!("setting {login}'s avatar")); + // The token goes whether or not the upload worked. + if let Err(e) = self + .api + .call(|api| { + api.admin_delete_user_access_token(login, TOKEN_NAME) + .into_future() + }) + .await + { + tracing::warn!(%login, error = %e, "service avatars: could not delete the avatar token"); + } + uploaded.map(|()| true) + } +} + +/// Set the service accounts' avatars now and every [`INTERVAL`]. Does nothing +/// when [`ICON_DIR_ENV`] is unset, as on a host without the assets. +pub fn spawn(client: Arc) { + let Some(icon_dir) = std::env::var_os(ICON_DIR_ENV).map(PathBuf::from) else { + tracing::warn!( + "{ICON_DIR_ENV} unset; the service accounts keep the forge's default avatar" + ); + return; + }; + let state_dir = crate::webhook::state_dir(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(INTERVAL); + loop { + ticker.tick().await; + let _ = super::within_forge_deadline( + "service avatars", + client.ensure_service_avatars(&icon_dir, &state_dir), + ) + .await; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A service with its own icon file uses it; one without falls back to the + /// logo, so a newly added service still gets an avatar. + #[test] + fn a_service_without_its_own_icon_gets_the_logo() { + let dir = std::env::temp_dir().join(format!("svc-avatars-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("temp dir"); + std::fs::write(dir.join("service-builder.png"), b"x").expect("icon"); + assert_eq!(pick_icon(&dir, "builder"), dir.join("service-builder.png")); + assert_eq!(pick_icon(&dir, "ci-metrics"), dir.join("hyperhive.png")); + std::fs::remove_dir_all(&dir).ok(); + } + + /// The marker changes with the image, so a re-rendered icon is uploaded + /// again, and only then. + #[test] + fn the_fingerprint_follows_the_image() { + assert_eq!(fingerprint(b"a"), fingerprint(b"a")); + assert_ne!(fingerprint(b"a"), fingerprint(b"b")); + assert_eq!(fingerprint(b"").len(), 64); + } + + #[test] + fn every_service_has_its_own_marker_and_icon_name() { + let state = Path::new("/state"); + let markers: std::collections::BTreeSet<_> = + SERVICES.iter().map(|l| marker(state, l)).collect(); + assert_eq!(markers.len(), SERVICES.len()); + assert_eq!( + icon_file("review-classifier"), + "service-review-classifier.png" + ); + } +} diff --git a/swarm-controller/src/startup.rs b/swarm-controller/src/startup.rs index 88de9c67b..54c0a5d0a 100644 --- a/swarm-controller/src/startup.rs +++ b/swarm-controller/src/startup.rs @@ -271,6 +271,7 @@ fn spawn_forge_workers( // pin when that is newer. The timer's first tick is this boot run. crate::jobq::spawn_meta_update_timer(Arc::clone(jobq)); forge::objects::spawn(Arc::clone(&client)); + forge::service_avatars::spawn(Arc::clone(&client)); if let (Some(bridge), Some(queue)) = (auth, queue.clone()) { forge::operator_profiles::spawn(Arc::clone(&client), bridge, queue); }