refactor(c0re): pass hive-c0re serve config via a --config file, shrink ExecStart

The systemd ExecStart carried every host-level setting as its own flag —
nine of them, including two escaped JSON blobs (the context-window map and
the model-price table). Collapse them into a single `--config <file>` JSON.

- Reuse the existing HiveEnv as the container-injected config shape (add
  Deserialize + Default), and add a ServeConfig wrapper = flattened HiveEnv
  plus the hive-c0re-local model_prices table (kept out of HiveEnv since it
  is never injected into containers). serde(default) lets any field be
  omitted and fall back to its canonical default.
- clap: add --config; the per-setting flags become optional overrides
  (config file is the base, explicit flags win — preserves hivectl/debug
  ergonomics and bare `hive-c0re serve`).
- Coordinator::open and cmd_serve now take the bundled HiveEnv, which drops
  their too_many_arguments clippy allows. cmd_serve keeps a single
  too_many_lines allow (inherent daemon-boot orchestration, not arg-driven).
- nix: write the config as JSON to the store + pass --config, so ExecStart
  is one short line.
- Add a round-trip test proving the flatten + per-field defaults work.

Closes the ExecStart-length issue.
This commit is contained in:
atlas 2026-06-08 21:28:01 +02:00 committed by mara
commit 98660d134a
3 changed files with 223 additions and 120 deletions

View file

@ -158,7 +158,14 @@ pub struct Coordinator {
/// Cloned from `Coordinator` via [`Coordinator::hive_env`]. All fields
/// are cheap to clone (small strings + small map); lifecycle ops are
/// infrequent enough that the copy cost is irrelevant.
#[derive(Clone, Debug)]
///
/// Also the container-injected subset of the `hive-c0re serve --config`
/// JSON (see [`ServeConfig`]): `#[serde(default)]` lets a partial config
/// fall back to [`Default`] (the canonical defaults that used to live in
/// the clap `default_value`s), and bare `hive-c0re serve` with no
/// `--config` boots from `Default`.
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(default)]
pub struct HiveEnv {
pub hyperhive_flake: String,
pub nixpkgs_flake: String,
@ -172,6 +179,78 @@ pub struct HiveEnv {
pub agent_memory_max: String,
}
impl Default for HiveEnv {
fn default() -> Self {
Self {
hyperhive_flake: "/etc/hyperhive".to_string(),
nixpkgs_flake: String::new(),
nixpkgs_unstable_flake: String::new(),
dashboard_port: 7000,
operator_pronouns: "she/her".to_string(),
context_window_tokens: std::collections::HashMap::from([
("haiku".to_string(), 200_000),
("sonnet".to_string(), 1_000_000),
("opus".to_string(), 1_000_000),
]),
agent_cpu_quota: "200%".to_string(),
agent_memory_max: "4G".to_string(),
}
}
}
/// On-disk shape of the `hive-c0re serve --config <file>` JSON: the
/// container-injected [`HiveEnv`] (flattened) plus the hive-c0re-local
/// `model_prices` table (read only by the `/api/stats-hive` handler, never
/// injected into containers — hence kept out of `HiveEnv`). The NixOS
/// module writes this so the `ExecStart` carries one `--config` flag
/// instead of every host-level setting as its own JSON-blob argument.
/// `#[serde(default)]` lets any field be omitted and fall back to its
/// canonical default.
#[derive(Clone, Debug, Default, serde::Deserialize)]
#[serde(default)]
pub struct ServeConfig {
#[serde(flatten)]
pub env: HiveEnv,
pub model_prices: crate::hive_stats::PriceTable,
}
#[cfg(test)]
mod serve_config_tests {
use super::ServeConfig;
#[test]
fn deserialises_flat_with_per_field_defaults() {
// The flattened HiveEnv fields + the local model_prices all live at
// the JSON top level. Present fields are read; omitted ones fall
// back to their canonical defaults (so a partial config is valid).
let sc: ServeConfig = serde_json::from_str(
r#"{
"hyperhive_flake": "/x",
"dashboard_port": 1234,
"model_prices": {
"opus": {"input": 5.0, "output": 25.0, "cache_read": 0.5, "cache_write": 10.0}
}
}"#,
)
.expect("flat ServeConfig should deserialise");
assert_eq!(sc.env.hyperhive_flake, "/x");
assert_eq!(sc.env.dashboard_port, 1234);
// omitted → HiveEnv::default()
assert_eq!(sc.env.operator_pronouns, "she/her");
assert_eq!(sc.env.agent_cpu_quota, "200%");
assert_eq!(sc.env.context_window_tokens.get("sonnet"), Some(&1_000_000));
assert!(sc.model_prices.contains_key("opus"));
}
#[test]
fn empty_object_is_all_defaults() {
let sc: ServeConfig = serde_json::from_str("{}").expect("empty config valid");
assert_eq!(sc.env.dashboard_port, 7000);
assert_eq!(sc.env.hyperhive_flake, "/etc/hyperhive");
assert!(sc.model_prices.is_empty());
}
}
/// Per-agent filesystem paths that lifecycle operations write to.
/// Assembled from `Coordinator`'s static path helpers so callers
/// don't repeat the same `Coordinator::agent_*_dir(name)` calls.
@ -271,24 +350,21 @@ impl TransientKind {
}
impl Coordinator {
#[allow(
clippy::too_many_arguments,
reason = "constructor wiring host-level config (flakes, ports, pronouns, \
context-window + resource limits) into the coordinator; bundling \
into a struct would just move the same fields one level out"
)]
pub fn open(
db_path: &Path,
hyperhive_flake: String,
nixpkgs_flake: String,
nixpkgs_unstable_flake: String,
dashboard_port: u16,
operator_pronouns: String,
context_window_tokens: std::collections::HashMap<String, u64>,
agent_cpu_quota: String,
agent_memory_max: String,
env: HiveEnv,
model_prices: crate::hive_stats::PriceTable,
) -> Result<Self> {
let HiveEnv {
hyperhive_flake,
nixpkgs_flake,
nixpkgs_unstable_flake,
dashboard_port,
operator_pronouns,
context_window_tokens,
agent_cpu_quota,
agent_memory_max,
} = env;
let broker = Broker::open(db_path).context("open broker")?;
let approvals = Approvals::open(db_path).context("open approvals")?;
let questions = OperatorQuestions::open(db_path).context("open operator_questions")?;

View file

@ -10,7 +10,7 @@ use hive_sh4re::{HostRequest, HostResponse};
// tree — no per-binary duplication. Enumerated rather than wildcard
// so clippy stays happy + the lib surface this bin consumes is
// explicit (any new daemon entry point reads off the next add).
use hive_c0re::coordinator::Coordinator;
use hive_c0re::coordinator::{Coordinator, HiveEnv, ServeConfig};
use hive_c0re::{
agent_sockets, auto_update, bash_tasks_vacuum, broker, client, crash_watch, dashboard,
dashboard_events, events_vacuum, forge, knowledge, manager_server, matrix, migrate,
@ -32,69 +32,64 @@ struct Cli {
enum Cmd {
/// Run the coordinator daemon.
Serve {
/// URL of the hyperhive flake. Inlined into each per-agent
/// `flake.nix` as the `hyperhive` input.
#[arg(long, default_value = "/etc/hyperhive")]
hyperhive_flake: String,
/// Store-path URL of the nixpkgs to wire into the meta flake as
/// `inputs.nixpkgs.url`. Set by the NixOS module to
/// `"path:${pkgs.path}"` so the meta flake tracks exactly the
/// nixpkgs the host was evaluated with (the host's own nixpkgs
/// when `inputs.hyperhive.inputs.nixpkgs.follows = "nixpkgs"` is
/// set, otherwise hyperhive's pin). Empty = legacy
/// `follows = "hyperhive/nixpkgs"` fallback.
#[arg(long, default_value = "")]
nixpkgs_flake: String,
/// Store-path URL of the nixpkgs-unstable to wire into the meta
/// flake as `inputs.nixpkgs-unstable.url`. Hyperhive's
/// `inputs.nixpkgs-unstable` then follows this top-level input.
/// Set by the NixOS module; defaults to the hyperhive flake's own
/// nixpkgs-unstable store path. Empty = legacy
/// `follows = "hyperhive/nixpkgs-unstable"` fallback.
#[arg(long, default_value = "")]
nixpkgs_unstable_flake: String,
/// Path to a JSON config file holding the host-level daemon config
/// (the [`ServeConfig`](hive_c0re::coordinator::ServeConfig) shape:
/// the container-injected `HiveEnv` fields + the hive-c0re-local
/// `model_prices` table). Used as the base; any per-flag override
/// below wins over the file. Absent → start from the built-in
/// defaults. The NixOS module passes a generated config here so the
/// `ExecStart` stays short instead of carrying every setting — and
/// the context-window / price-table JSON blobs — as flags.
#[arg(long)]
config: Option<PathBuf>,
/// Path to the sqlite message store.
#[arg(long, default_value = hive_c0re::paths::BROKER_DB)]
db: PathBuf,
/// Dashboard HTTP port.
#[arg(long, default_value_t = 7000)]
dashboard_port: u16,
/// Operator pronouns (free text). Threaded into each
/// container's harness via `HIVE_OPERATOR_PRONOUNS` so the
/// system prompt can mention them. Default: `she/her`.
#[arg(long, default_value = "she/her")]
operator_pronouns: String,
/// Per-model context-window sizes, as JSON object mapping model-family
/// short name to token count. Threaded into each container as
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars. Set via the
/// `services.hive-c0re.contextWindowTokens` NixOS option.
#[arg(
long,
default_value = r#"{"haiku":200000,"sonnet":1000000,"opus":1000000}"#
)]
context_window_tokens: String,
/// systemd `CPUQuota=` applied to every agent container via a drop-in.
/// Expressed as a percentage of one CPU core (e.g. `"200%"` = 2 cores).
/// Set via `services.hyperhive.agentCpuQuota`.
#[arg(long, default_value = "200%")]
agent_cpu_quota: String,
/// systemd `MemoryMax=` applied to every agent container.
/// Set via `services.hyperhive.agentMemoryMax`.
#[arg(long, default_value = "4G")]
agent_memory_max: String,
/// Per-model USD prices (per million tokens) for the hive-wide
/// ST4TS cost estimate, as a JSON object mapping a model-family
/// short name to `{input, output, cache_read, cache_write}`.
/// Keys are matched case-insensitively as a substring of the
/// model id; models not covered fall back to the built-in
/// Override: URL of the hyperhive flake. Inlined into each
/// per-agent `flake.nix` as the `hyperhive` input.
#[arg(long)]
hyperhive_flake: Option<String>,
/// Override: store-path URL of the nixpkgs to wire into the meta
/// flake as `inputs.nixpkgs.url`. Empty = legacy
/// `follows = "hyperhive/nixpkgs"` fallback.
#[arg(long)]
nixpkgs_flake: Option<String>,
/// Override: store-path URL of the nixpkgs-unstable to wire into
/// the meta flake as `inputs.nixpkgs-unstable.url`. Empty = legacy
/// `follows = "hyperhive/nixpkgs-unstable"` fallback.
#[arg(long)]
nixpkgs_unstable_flake: Option<String>,
/// Override: dashboard HTTP port.
#[arg(long)]
dashboard_port: Option<u16>,
/// Override: operator pronouns (free text). Threaded into each
/// container's harness via `HIVE_OPERATOR_PRONOUNS`.
#[arg(long)]
operator_pronouns: Option<String>,
/// Override: per-model context-window sizes, as a JSON object
/// mapping model-family short name to token count. Threaded into
/// each container as `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env
/// vars. Set via the `services.hive-c0re.contextWindowTokens`
/// NixOS option.
#[arg(long)]
context_window_tokens: Option<String>,
/// Override: systemd `CPUQuota=` applied to every agent container
/// via a drop-in (e.g. `"200%"` = 2 cores). Set via
/// `services.hyperhive.agentCpuQuota`.
#[arg(long)]
agent_cpu_quota: Option<String>,
/// Override: systemd `MemoryMax=` applied to every agent
/// container. Set via `services.hyperhive.agentMemoryMax`.
#[arg(long)]
agent_memory_max: Option<String>,
/// Override: per-model USD prices (per million tokens) for the
/// hive-wide ST4TS cost estimate, as a JSON object mapping a
/// model-family short name to `{input, output, cache_read,
/// cache_write}`. Models not covered fall back to the built-in
/// estimate. Set via the `services.hyperhive.modelPrices` NixOS
/// option (whose default carries the full opus/sonnet/haiku
/// table — kept in sync with the in-code `builtin_prices`).
/// Defaults to `{}` here so a bare `hive-c0re serve` leans
/// entirely on `builtin_prices`; the NixOS default (every real
/// deployment) shadows it with the same operator-tunable table.
#[arg(long, default_value = "{}")]
model_prices: String,
/// option.
#[arg(long)]
model_prices: Option<String>,
},
/// Spawn a new agent container directly (`hive-agent-<name>`). Bypasses
/// the approval queue — use only as an operator on the host. For
@ -156,10 +151,11 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Cmd::Serve {
config,
db,
hyperhive_flake,
nixpkgs_flake,
nixpkgs_unstable_flake,
db,
dashboard_port,
operator_pronouns,
context_window_tokens,
@ -167,20 +163,48 @@ async fn main() -> Result<()> {
agent_memory_max,
model_prices,
} => {
cmd_serve(
hyperhive_flake,
nixpkgs_flake,
nixpkgs_unstable_flake,
db,
dashboard_port,
operator_pronouns,
context_window_tokens,
agent_cpu_quota,
agent_memory_max,
model_prices,
&cli.socket,
)
.await
// Base config from the --config file (or the built-in
// defaults), then apply any per-flag overrides — config
// file is the base, explicit flags win.
let mut sc = match &config {
Some(p) => {
let s = std::fs::read_to_string(p)
.with_context(|| format!("read --config {}", p.display()))?;
serde_json::from_str::<ServeConfig>(&s)
.with_context(|| format!("parse --config {}", p.display()))?
}
None => ServeConfig::default(),
};
if let Some(v) = hyperhive_flake {
sc.env.hyperhive_flake = v;
}
if let Some(v) = nixpkgs_flake {
sc.env.nixpkgs_flake = v;
}
if let Some(v) = nixpkgs_unstable_flake {
sc.env.nixpkgs_unstable_flake = v;
}
if let Some(v) = dashboard_port {
sc.env.dashboard_port = v;
}
if let Some(v) = operator_pronouns {
sc.env.operator_pronouns = v;
}
if let Some(v) = context_window_tokens {
sc.env.context_window_tokens =
serde_json::from_str(&v).context("--context-window-tokens: invalid JSON")?;
}
if let Some(v) = agent_cpu_quota {
sc.env.agent_cpu_quota = v;
}
if let Some(v) = agent_memory_max {
sc.env.agent_memory_max = v;
}
if let Some(v) = model_prices {
sc.model_prices =
serde_json::from_str(&v).context("--model-prices: invalid JSON")?;
}
cmd_serve(sc.env, sc.model_prices, db, &cli.socket).await
}
Cmd::Spawn { name } => {
render(client::request(&cli.socket, HostRequest::Spawn { name }).await?)
@ -220,23 +244,16 @@ async fn main() -> Result<()> {
/// background tasks (auto-update, vacuums, crash-watcher, reminder-scheduler,
/// dashboard), then serve the admin socket until a signal arrives.
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
reason = "the `serve` subcommand's args are the host-level config the daemon \
boots from (flakes, ports, pronouns, context-window + resource \
limits); they flow straight through to Coordinator::open"
reason = "startup orchestration: open the broker, run migrations, then spawn \
the full set of background services (auto-update, vacuums, \
crash-watch, schedulers, dashboard) before the serve loop the \
length is inherent to booting the daemon, not the arg list"
)]
async fn cmd_serve(
hyperhive_flake: String,
nixpkgs_flake: String,
nixpkgs_unstable_flake: String,
env: HiveEnv,
model_prices: hive_c0re::hive_stats::PriceTable,
db: std::path::PathBuf,
dashboard_port: u16,
operator_pronouns: String,
context_window_tokens: String,
agent_cpu_quota: String,
agent_memory_max: String,
model_prices: String,
socket: &std::path::Path,
) -> Result<()> {
// Move any host-side state still at the legacy flat layout into its
@ -244,22 +261,10 @@ async fn cmd_serve(
// broker db — the broker + build-logs dbs are among the relocated
// files. Idempotent; a no-op once migrated.
hive_c0re::paths::relocate_legacy_state();
let cwt: std::collections::HashMap<String, u64> = serde_json::from_str(&context_window_tokens)
.context("--context-window-tokens: invalid JSON")?;
let prices: hive_c0re::hive_stats::PriceTable =
serde_json::from_str(&model_prices).context("--model-prices: invalid JSON")?;
let coord = Arc::new(Coordinator::open(
&db,
hyperhive_flake,
nixpkgs_flake,
nixpkgs_unstable_flake,
dashboard_port,
operator_pronouns,
cwt,
agent_cpu_quota,
agent_memory_max,
prices,
)?);
// `dashboard_port` is consumed into the Coordinator below; capture the
// Copy value first for the dashboard + knowledge-webhook tasks.
let dashboard_port = env.dashboard_port;
let coord = Arc::new(Coordinator::open(&db, env, model_prices)?);
manager_server::start(coord.clone())?;
// Idempotent pre-flight: rewrite pre-meta-layout applied
// repos, ensure proposed repos carry the `applied`

View file

@ -28,6 +28,28 @@ let
[safe]
directory = *
'';
# The `hive-c0re serve` config, written to the store as JSON and passed
# via a single `--config` flag so the systemd ExecStart line stays short
# instead of carrying every host-level setting as its own flag (the
# context-window + model-price maps alone were escaped JSON blobs on the
# command line). Keys are snake_case to match the `ServeConfig` serde
# shape the daemon deserialises (the container-injected HiveEnv fields,
# flattened, plus the hive-c0re-local model_prices table); per-flag
# overrides still work for ad-hoc invocations.
serveConfig = pkgs.writeText "hive-c0re-serve.json" (
builtins.toJSON {
hyperhive_flake = cfg.hyperhiveFlake;
nixpkgs_flake = cfg.nixpkgsFlake;
nixpkgs_unstable_flake = cfg.nixpkgsUnstableFlake;
dashboard_port = cfg.dashboardPort;
operator_pronouns = cfg.operatorPronouns;
context_window_tokens = cfg.contextWindowTokens;
agent_cpu_quota = cfg.agentCpuQuota;
agent_memory_max = cfg.agentMemoryMax;
model_prices = cfg.modelPrices;
}
);
in
{
# The forge is part of the standard install — hive-c0re mirrors
@ -736,7 +758,7 @@ in
);
};
serviceConfig = {
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --hyperhive-flake ${cfg.hyperhiveFlake} --nixpkgs-flake ${cfg.nixpkgsFlake} --nixpkgs-unstable-flake ${cfg.nixpkgsUnstableFlake} --dashboard-port ${toString cfg.dashboardPort} --operator-pronouns ${lib.escapeShellArg cfg.operatorPronouns} --context-window-tokens ${lib.escapeShellArg (builtins.toJSON cfg.contextWindowTokens)} --agent-cpu-quota ${lib.escapeShellArg cfg.agentCpuQuota} --agent-memory-max ${lib.escapeShellArg cfg.agentMemoryMax} --model-prices ${lib.escapeShellArg (builtins.toJSON cfg.modelPrices)}";
ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --config ${serveConfig}";
# Migrate hive-c0re's *own* state to the service user after an
# upgrade from a root-run install (systemd's StateDirectory only
# chowns the top-level dir, not pre-existing files inside it). The