diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index c7bf632..cdf4be3 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -380,15 +380,47 @@ async fn build_container_views( (out, any_stale) } -/// Parse `/var/lib/hyperhive/meta/flake.lock` into a map of node name -/// (`agent-`, `hyperhive`) → locked sha. Missing / unparsable lock -/// yields an empty map so the dashboard degrades gracefully when the -/// meta repo hasn't been seeded yet. +/// Map of node name → locked sha for nodes the **root** of meta +/// directly depends on (`hyperhive`, `agent-`). Used by the +/// container row to render its `deployed:` chip per agent. +/// Distinct from `read_meta_inputs()` which walks deeper for the +/// flake-input update form. fn read_meta_locked_revs() -> std::collections::HashMap { - read_meta_inputs() - .into_iter() - .map(|i| (i.name, i.rev)) - .collect() + let mut out = std::collections::HashMap::new(); + let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else { + return out; + }; + let Ok(json) = serde_json::from_str::(&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 } #[derive(Serialize, Clone)] @@ -406,10 +438,20 @@ struct MetaInputView { url: Option, } -/// Walk `flake.lock`'s `nodes` map → `Vec`. Only -/// includes nodes the root depends on (i.e. real inputs), skipping -/// the synthetic `root` entry. Sorted with `hyperhive` first then -/// alphabetically so the UI's top entry is the swarm-wide base. +/// Walk `flake.lock`'s `nodes` graph from `root` and emit one +/// `MetaInputView` per fetched input, up to two levels deep. That +/// surfaces the direct meta inputs (`hyperhive`, `agent-`) AND +/// the agent flakes' own inputs (`agent-dmatrix/mcp-matrix`, +/// `hyperhive/nixpkgs`, etc.) so the operator can bump them +/// individually from the UI. Deeper transitive nodes aren't shown +/// to keep the panel readable — bumping the level-2 entry will +/// re-fetch its own sub-inputs anyway. Names are slash-separated +/// paths from root, which is the syntax `nix flake update` accepts +/// for transitive inputs. +/// +/// Inputs that resolve via a `follows` chain (lock value is an +/// Array of strings) are skipped — they're aliases, not their own +/// fetched derivation, and updating them does nothing. fn read_meta_inputs() -> Vec { let mut out = Vec::new(); let Ok(raw) = std::fs::read_to_string("/var/lib/hyperhive/meta/flake.lock") else { @@ -424,40 +466,9 @@ fn read_meta_inputs() -> Vec { let Some(root_name) = json.get("root").and_then(|v| v.as_str()) else { return out; }; - let root_inputs: std::collections::BTreeSet = nodes - .get(root_name) - .and_then(|n| n.get("inputs")) - .and_then(|v| v.as_object()) - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default(); - for (name, node) in nodes { - if !root_inputs.contains(name) { - continue; - } - let locked = node.get("locked"); - let Some(rev) = locked - .and_then(|v| v.get("rev")) - .and_then(|v| v.as_str()) - else { - continue; - }; - let last_modified = locked - .and_then(|v| v.get("lastModified")) - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - let url = node - .get("original") - .and_then(|v| v.get("url")) - .and_then(|v| v.as_str()) - .map(str::to_owned); - out.push(MetaInputView { - name: name.clone(), - rev: rev.to_owned(), - last_modified, - url, - }); - } - // hyperhive first, then alphabetical. + walk_meta_inputs(nodes, root_name, "", 0, 2, &mut out); + // hyperhive first, then alphabetical (sub-paths sort under their + // parent, which gives a tidy 'agent-foo, agent-foo/bar' grouping). out.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) { ("hyperhive", _) => std::cmp::Ordering::Less, (_, "hyperhive") => std::cmp::Ordering::Greater, @@ -466,6 +477,65 @@ fn read_meta_inputs() -> Vec { out } +fn walk_meta_inputs( + nodes: &serde_json::Map, + node_name: &str, + prefix: &str, + depth: u32, + max_depth: u32, + out: &mut Vec, +) { + if depth >= max_depth { + return; + } + let Some(node) = nodes.get(node_name) else { + return; + }; + let Some(inputs_map) = node.get("inputs").and_then(|v| v.as_object()) else { + return; + }; + for (alias, target) in inputs_map { + // Inputs map value is either a string (node name) or an + // array (a `follows` chain). The latter just aliases another + // node — we can't `nix flake update` it directly, so skip. + let target_name = match target { + serde_json::Value::String(s) => s.clone(), + _ => continue, + }; + let Some(target_node) = nodes.get(&target_name) else { + continue; + }; + let path = if prefix.is_empty() { + alias.clone() + } else { + format!("{prefix}/{alias}") + }; + if let Some(rev) = target_node + .get("locked") + .and_then(|v| v.get("rev")) + .and_then(|v| v.as_str()) + { + let last_modified = target_node + .get("locked") + .and_then(|v| v.get("lastModified")) + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + let url = target_node + .get("original") + .and_then(|v| v.get("url")) + .and_then(|v| v.as_str()) + .map(str::to_owned); + out.push(MetaInputView { + name: path.clone(), + rev: rev.to_owned(), + last_modified, + url, + }); + } + walk_meta_inputs(nodes, &target_name, &path, depth + 1, max_depth, out); + } +} + /// Transient state for agents whose container does NOT yet exist /// (`Spawning`). Lifecycle ops on existing containers surface as /// `ContainerView.pending` inline; this list only catches pre-creation. @@ -917,11 +987,18 @@ async fn run_meta_update(coord: &Arc, inputs: & return; } - // Decide which agents to rebuild. - let touched_hyperhive = inputs.iter().any(|i| i == "hyperhive"); + // Decide which agents to rebuild. Inputs are slash-paths from + // the meta root — `hyperhive`, `hyperhive/nixpkgs`, + // `agent-coder`, `agent-coder/mcp-matrix`, etc. Anything in the + // hyperhive subtree affects every agent (shared base); anything + // in `agent-/...` only the named agent. + let touched_hyperhive = inputs + .iter() + .any(|i| i == "hyperhive" || i.starts_with("hyperhive/")); let touched_agents: Vec = inputs .iter() - .filter_map(|i| i.strip_prefix("agent-").map(str::to_owned)) + .filter_map(|i| i.strip_prefix("agent-")) + .map(|rest| rest.split('/').next().unwrap_or(rest).to_owned()) .collect(); let agents_to_rebuild: Vec = if touched_hyperhive { crate::lifecycle::list()