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")?;