hyperhive/nix/modules/hive-c0re.nix
atlas aa7ebbcd3b feat: add hive-ci module — Forgejo Actions runner for CI
Adds `services.hyperhive.ci` NixOS module that spins up a `hive-ci`
nixos-container running `gitea-actions-runner` against the hive-forge
Forgejo instance. Off by default; opt in with `ci.enable = true` after
generating a runner registration token in Forgejo.

Also adds `.forgejo/workflows/ci.yml` with four jobs: nix flake check,
formatting (nix fmt + cargo fmt), cargo test, and cargo clippy. Jobs
target the `hive-ci` runner label.

Container design mirrors hive-forge (shared host netns, non-ephemeral
state, loopback reach to forge). sandbox-fallback = true since nspawn
containers can't create user-namespaces for nix sandbox.

Closes #175.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:44:12 +02:00

391 lines
16 KiB
Nix

{
hyperhivePackage,
hyperhiveFrontend,
hyperhiveAssets,
hyperhiveFlake,
agentBaseToplevel,
managerToplevel,
}:
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.c0re;
in
{
# The forge is part of the standard install — hive-c0re mirrors
# every agent's applied config repo into it. On by default; opt out
# with `services.hyperhive.forge.enable = false`. hive-matrix is
# opt-in (off by default) and asserts that `services.hyperhive.domain`
# is set before it can be enabled.
imports = [
./hive-ci.nix
./hive-forge.nix
./hive-gateway.nix
./hive-matrix.nix
./hive-network.nix
];
# Top-level hyperhive enable flag. When true, automatically enables
# hive-c0re and the on-by-default hyperhive subsystems.
options.services.hyperhive.enable = lib.mkEnableOption "hyperhive the agent swarm coordinator";
# Canonical hive DNS domain shared by every subsystem that needs a
# stable hostname. Nullable + default null so existing configs
# evaluate unchanged; subsystems that need it (matrix) assert
# non-null in their own config block. Full identity-surface
# context (HYPERHIVE_HIVE_DOMAIN / HIVE_NAME / SWARM_NAME env-var
# chain → identity.rs → claude prompt): docs/conventions.md::
# Hive identity (label + domain + display names).
options.services.hyperhive.domain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "darkest.space";
description = ''
Canonical host domain for hyperhive subsystems that need a
stable name (currently: `services.hyperhive.matrix.serverName`
derives from this, defaulting to
`matrix.''${services.hyperhive.domain}` when `serverName` is
null). No default subsystems that opt to require it assert
non-null in their own config and fail eval with a helpful
message if it's missing. Exposed to agents as
`HYPERHIVE_HIVE_DOMAIN`; consumed by
`hive-ag3nt::identity::hive_domain()` for `<name>@<domain>`
qualified labels.
'';
};
# Human display names for hive + swarm. Distinct from the DNS
# domain above (machine-readable) — see
# docs/conventions.md::Hive identity for the
# domain-vs-name-vs-swarm distinction + the env-var
# propagation chain.
options.services.hyperhive.hiveName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "pr1ma";
description = ''
Human-readable name of this single-host hive instance.
Distinct from `services.hyperhive.domain` (the machine-
addressable DNS name): the domain may carry the hive name as
its leftmost label by convention, but this option is the
canonical readable identity. Exposed to agents as
`HYPERHIVE_HIVE_NAME`; surfaced in the dashboard chrome and
per-agent system prompt when set. Null falls back to the
pre-#701 behaviour (chrome shows the domain, prompt doesn't
mention a hive name).
'';
};
options.services.hyperhive.swarmName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "constellat1on";
description = ''
Human-readable name of the wider swarm this hive belongs to.
Hives at different DNS domains can share a swarm name when
they federate together. Exposed to agents as
`HYPERHIVE_SWARM_NAME`; surfaced in the dashboard chrome and
per-agent system prompt when set.
'';
};
# Peer hives in the same swarm. Each entry declares a remote hive
# reachable from this host. Serialised to JSON and injected as
# `HYPERHIVE_PEERS` into the hive-c0re service and forwarded to agent
# containers via `meta.rs::FORWARDED_VARS`. Consumed by
# `identity.rs::peers()` + the dashboard's `peer_hives` state field
# (feeds the P33RS dashboard tab).
options.services.hyperhive.peers = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
domain = lib.mkOption {
type = lib.types.str;
example = "lab.example.com";
description = ''
DNS domain of the peer hive. Used to construct the peer's
dashboard URL (`http://''${domain}/`) and for Matrix
federation auto-discovery (`matrix.''${domain}`).
Must be reachable from this host.
'';
};
tlsCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Optional path to a PEM cert/bundle to trust for this peer's
TLS. Null = system CA bundle (for Let's Encrypt peers). Set
to the peer's self-signed cert for `selfSignedTls = true`
peers. Forward-compat slot; not yet used in v0.
'';
};
};
}
);
default = { };
example = {
lab = {
domain = "lab.example.com";
};
edge = {
domain = "edge.corp";
};
};
description = ''
Peer hives in the same swarm. The attrset key is a short label
used in dashboard links and log messages -- it does not need to
match the remote hive's `hiveName`. Null `tlsCertFile` uses the
system CA bundle; set it for self-signed TLS peers (forward-compat
slot, not yet used in v0).
'';
};
options.services.hyperhive.c0re = {
enable = lib.mkOption {
type = lib.types.bool;
default = config.services.hyperhive.enable;
defaultText = lib.literalExpression "config.services.hyperhive.enable";
description = "Enable hive-c0re coordinator daemon (auto-enabled by services.hyperhive.enable).";
};
package = lib.mkOption {
type = lib.types.package;
default = hyperhivePackage pkgs.stdenv.hostPlatform.system;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.default";
description = ''
hyperhive workspace package. Provides `/bin/hive-c0re`
(coordinator daemon + admin-socket CLI) and `/bin/hivectl`
(operator-facing host CLI for ad-hoc administration; #655).
'';
};
frontend = lib.mkOption {
type = lib.types.package;
default = hyperhiveFrontend pkgs.stdenv.hostPlatform.system;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend";
description = ''
Bundled frontend dist (see `./nix/frontend.nix`). Output has
`dashboard/` and `agent/` subdirectories hive-c0re serves
`dashboard/` via `tower_http::ServeDir` from the path passed
in `HIVE_STATIC_DIR`. Override to ship a custom dashboard SPA;
the JSON contract (`/api/state`, the SSE streams, the action
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;
defaultText = lib.literalMD "the flake's own store path";
description = ''
URL of the hyperhive flake (no fragment). Inlined into each
per-agent `flake.nix` at `inputs.hyperhive.url`. The per-agent
flake then pulls `hyperhive.nixosConfigurations.agent-base` to
build the container. Defaults to this flake's own store path
only override if you want agents tracking a different ref.
'';
};
dashboardPort = lib.mkOption {
type = lib.types.port;
default = 7000;
description = "TCP port the hive-c0re dashboard listens on.";
};
operatorPronouns = lib.mkOption {
type = lib.types.str;
default = "she/her";
example = "they/them";
description = ''
Operator pronouns, free text. Threaded into every agent
container as the `HIVE_OPERATOR_PRONOUNS` env var; the
harness substitutes it into the agent / manager system
prompt at boot so claude refers to the operator naturally
in third person ("ask her", "tell them", etc.). Changes
propagate to running agents on the next ` R3BU1LD`
forwards as a meta flake env-var bump, no per-agent
approval needed.
'';
};
preBuildAgentTemplates = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Pre-fetch the per-container system closures (agent-base +
manager toplevels) into the host's /nix/store as part of this
host's NixOS build, instead of letting the first agent spawn
do all the work. Closes #97.
Enabling this adds roughly the full nixpkgs runtime closure +
claude-code + the harness binary to your system closure size
(low single-digit GB), but the first `nixos-container start`
for any agent then completes in seconds instead of minutes
because nothing's left to fetch.
Off by default because the toplevels are pinned to
`x86_64-linux` (nixos-containers run native arch). Enabling
on an aarch64 host would force nix to build the x86 closure
via cross or a remote builder, which is rarely what you want.
Flip to `true` on an x86_64 host when you care more about
first-spawn latency than host store size or just
`nix build ${hyperhiveFlake}#agent-base-toplevel` once
manually to warm the store.
'';
};
contextWindowTokens = lib.mkOption {
type = lib.types.attrsOf lib.types.int;
default = {
haiku = 200000;
sonnet = 1000000;
opus = 1000000;
};
example = {
haiku = 150000;
sonnet = 900000;
};
description = ''
Per-model context-window sizes in tokens. Each key is a
model-family short name matched case-insensitively as a
substring of the active model name at runtime (e.g. `"sonnet"`
matches `"claude-sonnet-4-5"`). The defaults cover the known
Anthropic families; add entries for new models or override
existing ones here to change the window for all agents at once.
Passed to `hive-c0re serve` as JSON and injected into every
container's harness service environment as
`HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>`. Changes propagate
on the next ` R3BU1LD` no per-agent approval needed.
'';
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [
cfg.package
pkgs.git
];
# Pull the per-container toplevels into the host system closure
# (#97). `system.extraDependencies` adds paths to the system build
# without referencing them at runtime — nixos-rebuild fetches /
# builds them, they end up in /nix/store, and the first
# nixos-container update + start for an agent has nothing left to
# do. Gated because the closure is sizeable and pinned to x86_64.
system.extraDependencies = lib.optionals cfg.preBuildAgentTemplates [
agentBaseToplevel
managerToplevel
];
# Open the per-agent web-port range when the gateway is *off* —
# otherwise the gateway nginx is the sole external entry point.
# See `docs/gateway.md::Firewall posture (host-level)`.
networking.firewall = lib.mkIf (!config.services.hyperhive.gateway.enable) {
allowedTCPPortRanges = [
{
from = 8100;
to = 8999;
}
];
};
systemd.services.hive-c0re = {
description = "hyperhive coordinator daemon";
wantedBy = [ "multi-user.target" ];
path = [
pkgs.git
"/run/current-system/sw"
];
environment = {
HYPERHIVE_GIT = "${pkgs.git}/bin/git";
# Path to the dashboard static dist. The hive-c0re axum router
# 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.services.hyperhive.domain != null) {
# Identity env vars threaded into c0re's own service env and
# forwarded by meta.rs into every sub-agent's harness env —
# full chain in docs/conventions.md::Hive identity.
HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain;
}
// lib.optionalAttrs (config.services.hyperhive.hiveName != null) {
HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName;
}
// lib.optionalAttrs (config.services.hyperhive.swarmName != null) {
HYPERHIVE_SWARM_NAME = config.services.hyperhive.swarmName;
}
// lib.optionalAttrs config.services.hyperhive.forge.enable {
# Loopback for in-cluster calls (agents share host netns;
# external `forge.<hive>` sub-domain isn't DNS-resolvable
# from inside nspawn). See
# `docs/gateway.md::HIVE_FORGE_URL: loopback for in-cluster,
# sub-domain for the operator`.
HIVE_FORGE_URL = "http://127.0.0.1:${toString config.services.hyperhive.forge.httpPort}";
}
// lib.optionalAttrs config.services.hyperhive.matrix.gui.enable {
# Availability flags read by the dashboard's `/api/state`.
# Matrix GUI lives entirely on the gateway nginx (matrix tab
# only shows when both flags are on). Gateway routing detail:
# docs/gateway.md::Vhost map.
HIVE_MATRIX_GUI_ENABLED = "1";
}
// lib.optionalAttrs config.services.hyperhive.gateway.enable {
# When true the dashboard builds same-origin `/agent/<name>/`
# links; when false it falls back to direct `<host>:<port>` TCP.
HIVE_GATEWAY_ENABLED = "1";
}
//
lib.optionalAttrs
(config.services.hyperhive.forge.enable && config.services.hyperhive.forge.behindGateway)
{
# Public URL of the forge vhost served by hive-gateway. The
# dashboard uses this to build browser-facing forge links
# instead of hardcoding `<hostname>:3000`, which breaks when
# the operator accesses the dashboard through the gateway
# (forge sub-domain has no port; direct port URL would be
# wrong). Absent when `behindGateway = false` — dashboard
# falls back to `<hostname>:3000`.
HIVE_FORGE_PUBLIC_URL = "https://${config.services.hyperhive.forge.domain}";
}
// lib.optionalAttrs (config.services.hyperhive.peers != { }) {
# Peer hives serialised as a JSON array of {label, domain} objects.
# Consumed by hive-ag3nt::identity::peers() + the dashboard's
# peer_hives StateSnapshot field (P33RS tab). tlsCertFile is
# nix-side-only (host nginx/trust config); rust never needs the path.
HYPERHIVE_PEERS = builtins.toJSON (
lib.mapAttrsToList (label: p: {
inherit label;
inherit (p) domain;
}) config.services.hyperhive.peers
);
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)}";
Restart = "on-failure";
RestartSec = 2;
RuntimeDirectory = "hyperhive";
RuntimeDirectoryMode = "0750";
RuntimeDirectoryPreserve = "yes";
StateDirectory = "hyperhive";
};
};
};
}