Watch
0
0
Fork
You've already forked hyperhive
0

build agents only from internal/meta; delete the hive's local meta flake

The hive no longer renders, locks or commits a meta flake. A rebuild's
MetaSync node picks the internal/meta commit and records it with the
hyperhive and config pins it holds; Prebuild, Swap and Create build from it,
and a head that does not pin the agent's current config fails the build
naming the agent. hive-priv's meta_rev is required.

Removed: meta.rs sync/lock/render, the manual-rebuild relock, the boot
sweep's hyperhive bump (MetaLock), the update-meta-inputs form and its
panel, MetaWindow, the hive's hyperhive_rev setting.

Kept: agent_set::refresh holds what sync_agents also did (topology.json, the
gateway's agent-socket map and nginx include), called from spawn, destroy,
rebuild and the migration.

The boot reconcile and the stale-start upgrade compare the agent's marker
with the hyperhive pin of internal/meta; the dashboard's deployed chip reads
the config marker a successful build writes.
This commit is contained in:
atlas 2026-10-25 13:33:15 +01:00
commit 4aa11b3a94
64 changed files with 1042 additions and 3793 deletions

View file

@ -5,7 +5,6 @@
//! diff against the last snapshot to fire
//! `ContainerStateChanged` / `ContainerRemoved` events.
use std::collections::HashMap;
use std::path::Path;
use chrono::{DateTime, Utc};
@ -45,8 +44,8 @@ pub struct ContainerView {
pub failed: bool,
pub needs_update: bool,
pub needs_login: bool,
/// First 12 chars of the sha the meta flake currently has locked
/// for this agent's input.
/// First 12 chars of the config commit the agent's last successful build
/// pinned (`paths::applied_config_marker`).
#[serde(skip_serializing_if = "Option::is_none")]
pub deployed_sha: Option<String>,
/// The Claude model the agent's harness is currently using, read from
@ -142,7 +141,7 @@ fn agent_url_for_domain(domain: &str, name: &str) -> String {
/// The container list could not be read.
pub async fn build_all() -> anyhow::Result<Vec<ContainerView>> {
let raw = lifecycle::list().await?;
let locked = read_meta_locked_revs();
let mut out = Vec::new();
for c in &raw {
let Some(logical) = c.strip_prefix(AGENT_PREFIX) else {
@ -154,12 +153,12 @@ pub async fn build_all() -> anyhow::Result<Vec<ContainerView>> {
let Ok(logical) = hive_types::Ident::parse(logical) else {
continue;
};
let deployed_full = locked
.get(&format!("agent-{logical}"))
.map(std::string::String::as_str);
let deployed_full =
std::fs::read_to_string(crate::paths::applied_config_marker(logical.as_str())).ok();
let needs_update =
crate::auto_update::agent_config_pending(logical.as_str(), deployed_full).await;
let deployed_sha = deployed_full.map(short_rev);
crate::auto_update::agent_config_pending(logical.as_str(), deployed_full.as_deref())
.await;
let deployed_sha = deployed_full.as_deref().map(short_rev);
// One `systemctl` call for both facts: `unit_state` is the same
// shell-out `is_running` makes, minus `--quiet`. Asking twice would
// double the per-agent subprocess count on every SSE scan.
@ -380,64 +379,15 @@ pub fn hive_swarm_names() -> (Option<String>, Option<String>) {
(read("HYPERHIVE_HIVE_NAME"), read("HYPERHIVE_SWARM_NAME"))
}
/// Map of `agent-<n>` → locked sha from meta's flake.lock. Used to
/// render the `deployed:<sha12>` chip per container row.
fn read_meta_locked_revs() -> HashMap<String, String> {
let Ok(raw) = std::fs::read_to_string(crate::paths::meta_flake_lock()) else {
return HashMap::new();
};
parse_locked_revs(&raw)
}
/// The parsing half of [`read_meta_locked_revs`], split out so it can be
/// exercised without a `flake.lock` on disk.
///
/// Every failure is the same empty map: the chip is decoration, and a
/// malformed lock must not take the dashboard down.
fn parse_locked_revs(raw: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) else {
return out;
};
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return out;
};
let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else {
return out;
};
let Some(root_inputs) = nodes
.get(root_name)
.and_then(|n| n.get("inputs"))
.and_then(|v| v.as_object())
else {
return out;
};
for alias in root_inputs.keys() {
let target_name = match root_inputs.get(alias) {
Some(serde_json::Value::String(s)) => s.clone(),
_ => continue,
};
if let Some(rev) = nodes
.get(&target_name)
.and_then(|n| n.get("locked"))
.and_then(|v| v.get("rev"))
.and_then(|v| v.as_str())
{
out.insert(alias.clone(), rev.to_owned());
}
}
out
}
/// First 12 characters of a rev. Counts chars, not bytes: the value comes
/// from `flake.lock` JSON, and a byte slice at 12 can split a multi-byte char.
/// First 12 characters of a rev. Counts chars, not bytes, so a byte slice at 12
/// cannot split a multi-byte char.
fn short_rev(rev: &str) -> String {
rev.chars().take(12).collect()
}
#[cfg(test)]
mod tests {
use super::{parse_locked_revs, short_rev};
use super::short_rev;
#[test]
fn short_rev_truncates_and_keeps_short() {
@ -452,77 +402,6 @@ mod tests {
assert_eq!(short_rev(rev), "0123456789aé");
}
/// Shape of a real `meta/flake.lock`: `root` names a node whose
/// `inputs` map alias → node name, and each node carries `locked.rev`.
fn lock(inputs: &str, nodes: &str) -> String {
format!(r#"{{"root":"root","nodes":{{"root":{{"inputs":{{{inputs}}}}},{nodes}}}}}"#)
}
/// Keyed by the **alias** (what the chip renders), not by the node
/// name it points at. Those differ whenever nix dedupes a node —
/// `agent-bob` → `agent-bob_2` — so the fixture makes them differ,
/// otherwise the test cannot tell the two keyings apart.
#[test]
fn maps_each_alias_to_its_locked_rev() {
let raw = lock(
r#""agent-alice":"agent-alice","agent-bob":"agent-bob_2""#,
r#""agent-alice":{"locked":{"rev":"aaa111"}},"agent-bob_2":{"locked":{"rev":"bbb222"}}"#,
);
let got = parse_locked_revs(&raw);
assert_eq!(got.get("agent-alice").map(String::as_str), Some("aaa111"));
assert_eq!(
got.get("agent-bob").map(String::as_str),
Some("bbb222"),
"keyed by alias, not by the deduped node name"
);
assert!(!got.contains_key("agent-bob_2"));
assert_eq!(got.len(), 2);
}
/// A `follows` input is stored as an array of path segments, not a
/// node name. Skipping it is why the match arm is a `continue`.
#[test]
fn a_follows_input_is_skipped_without_losing_its_siblings() {
let raw = lock(
r#""agent-alice":"agent-alice","nixpkgs":["agent-alice","nixpkgs"]"#,
r#""agent-alice":{"locked":{"rev":"aaa111"}}"#,
);
let got = parse_locked_revs(&raw);
assert_eq!(got.len(), 1, "the sibling still resolves");
assert!(!got.contains_key("nixpkgs"));
}
#[test]
fn an_input_whose_node_has_no_rev_is_skipped() {
let raw = lock(
r#""agent-alice":"agent-alice","agent-bob":"agent-bob""#,
r#""agent-alice":{"locked":{"rev":"aaa111"}},"agent-bob":{"locked":{}}"#,
);
let got = parse_locked_revs(&raw);
assert_eq!(got.len(), 1);
assert!(got.contains_key("agent-alice"));
}
/// Every malformed shape yields an empty map rather than a panic —
/// the chip is decoration and must not take the dashboard down.
#[test]
fn malformed_input_yields_an_empty_map() {
for raw in [
"not json at all",
"{}",
r#"{"root":"root"}"#,
r#"{"nodes":{"root":{"inputs":{}}}}"#,
r#"{"root":"missing","nodes":{"root":{"inputs":{"a":"a"}}}}"#,
] {
assert!(parse_locked_revs(raw).is_empty(), "for input: {raw}");
}
// Control: the well-formed shape these are degraded from does
// resolve, so the emptiness above is the guard and not the parser
// being inert.
let ok = lock(r#""a":"a""#, r#""a":{"locked":{"rev":"abc"}}"#);
assert_eq!(parse_locked_revs(&ok).len(), 1);
}
/// The `From` impl both `handle_agent_status`'s poll path and
/// `stream_agent_status`'s push path go through — pins the field
/// mapping so the two can't quietly diverge, and that `port` (no