From 98660d134a2e03433611236e1336f1845f172ccd Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 8 Jun 2026 21:28:01 +0200 Subject: [PATCH] refactor(c0re): pass hive-c0re serve config via a --config file, shrink ExecStart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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. --- hive-c0re/src/coordinator.rs | 106 ++++++++++++++--- hive-c0re/src/main.rs | 213 ++++++++++++++++++----------------- nix/modules/hive-c0re.nix | 24 +++- 3 files changed, 223 insertions(+), 120 deletions(-) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 690bc2d1..68d352e1 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -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 ` 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, - agent_cpu_quota: String, - agent_memory_max: String, + env: HiveEnv, model_prices: crate::hive_stats::PriceTable, ) -> Result { + 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")?; diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index e37fec13..60413952 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -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, /// 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_` 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, + /// 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, + /// 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, + /// Override: dashboard HTTP port. + #[arg(long)] + dashboard_port: Option, + /// Override: operator pronouns (free text). Threaded into each + /// container's harness via `HIVE_OPERATOR_PRONOUNS`. + #[arg(long)] + operator_pronouns: Option, + /// 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_` env + /// vars. Set via the `services.hive-c0re.contextWindowTokens` + /// NixOS option. + #[arg(long)] + context_window_tokens: Option, + /// 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, + /// Override: systemd `MemoryMax=` applied to every agent + /// container. Set via `services.hyperhive.agentMemoryMax`. + #[arg(long)] + agent_memory_max: Option, + /// 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, }, /// Spawn a new agent container directly (`hive-agent-`). 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::(&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 = 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` diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index 6f3e9396..d6425ebf 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -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