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:
parent
cea4827918
commit
4aa11b3a94
64 changed files with 1042 additions and 3793 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue